sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
# Package: dirigent_core
|
||||
|
||||
Core orchestration engine for multi-connector agent system management.
|
||||
|
||||
## Quick Facts
|
||||
- **Type**: Library
|
||||
- **Main Entry**: src/lib.rs
|
||||
- **Dependencies**: dirigent_protocol, tokio, axum, serde, uuid
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The dirigent_core package provides a **runtime-based architecture** for managing long-lived connections to external agent systems (OpenCode.ai, ACP agents, etc.). The core abstraction is the **Connector**, which represents a bidirectional communication channel to an agent system.
|
||||
|
||||
### Core Components
|
||||
|
||||
#### CoreRuntime
|
||||
The central orchestrator for managing connectors. It maintains:
|
||||
- Registry of active connectors (keyed by ConnectorId)
|
||||
- Global event broadcast channel for system-wide events
|
||||
- User registry for ownership and authorization
|
||||
- Configuration state (with persistence support)
|
||||
|
||||
#### CoreHandle
|
||||
Lightweight, cloneable wrapper around CoreRuntime. Uses Arc internally for cheap cloning across async tasks and server functions.
|
||||
|
||||
#### Connector Trait
|
||||
Defines the interface for connector implementations:
|
||||
- Command channel (mpsc) for control operations
|
||||
- Event broadcast channel for publishing events
|
||||
- State tracking (Initializing, Connecting, Ready, Error, Stopped)
|
||||
- User ownership for authorization
|
||||
|
||||
#### ConnectorHandle
|
||||
Concrete implementation of the Connector trait that wraps:
|
||||
- Metadata (id, kind, owner, title)
|
||||
- Shared state (protected by RwLock)
|
||||
- Command and event channels
|
||||
- Optional task handle for lifecycle management
|
||||
|
||||
### Connector Implementations
|
||||
|
||||
#### OpenCodeConnector
|
||||
Connector for OpenCode.ai REST + SSE API:
|
||||
- HTTP client for session/message operations
|
||||
- SSE event stream for real-time updates
|
||||
- Background task loop for command processing
|
||||
- State machine for connection lifecycle
|
||||
- **TurnComplete emission**: Uses `TurnCompleteTrigger::ExplicitSignal` after `MessageCompleted` events (based on upstream session.idle signals)
|
||||
|
||||
#### AcpConnector (Future)
|
||||
Connector for Agent-Client Protocol:
|
||||
- WebSocket or HTTP/2 transport
|
||||
- ACP message protocol handling
|
||||
- Tool execution and streaming support
|
||||
|
||||
## Key Files
|
||||
|
||||
### Core Runtime
|
||||
- `src/runtime.rs` - CoreRuntime and CoreHandle implementation
|
||||
- `src/types.rs` - Core types (ConnectorId, ConnectorState, User, etc.)
|
||||
- `src/error.rs` - Error types for the runtime
|
||||
- `src/config.rs` - Configuration types and template system
|
||||
|
||||
### Connectors
|
||||
- `src/connectors/mod.rs` - Connector trait and ConnectorHandle
|
||||
- `src/connectors/opencode/mod.rs` - OpenCode connector implementation
|
||||
- `src/connectors/opencode/config.rs` - OpenCode-specific configuration
|
||||
- `src/connectors/acp/mod.rs` - ACP connector implementation (in progress)
|
||||
|
||||
### ACP Protocol Implementation
|
||||
- `src/acp/protocol/initialize.rs` - Protocol initialization and capability negotiation
|
||||
- `src/acp/protocol/authenticate.rs` - Optional authentication flow
|
||||
- `src/acp/protocol/session.rs` - Session lifecycle (new, load, set_mode, cancel)
|
||||
- `src/acp/protocol/prompt.rs` - Prompt requests with content blocks (Phases 3-7 complete)
|
||||
- `src/acp/protocol/streaming.rs` - Session update notifications and handlers
|
||||
- `src/acp/protocol/stop_reason.rs` - Stop reason interpretation and actions
|
||||
- `src/acp/protocol/cancellation.rs` - Cancellation and disconnect handling
|
||||
- `src/acp/protocol/error.rs` - Error classification and retry logic
|
||||
- `src/acp/connector_state.rs` - Connection and session state management
|
||||
- `src/acp/transport/mod.rs` - Transport abstraction layer
|
||||
- `src/acp/transport/stdio.rs` - Stdio transport (process spawning)
|
||||
- `src/acp/transport/http.rs` - HTTP+SSE transport
|
||||
|
||||
### Bidirectional Request Handling Pattern
|
||||
|
||||
The ACP connector implements **true bidirectional communication** where both client and agent can send JSON-RPC requests at any time. This creates an architectural challenge: how to maintain synchronous request/response semantics (async/await) while handling incoming agent requests during outgoing client requests.
|
||||
|
||||
**The Challenge:**
|
||||
|
||||
When the connector sends `session/prompt` to the agent:
|
||||
1. It calls `send_request()` which awaits the JSON-RPC response
|
||||
2. The agent may send permission requests (e.g., `tools/write`) before responding
|
||||
3. These agent requests arrive as `ConnectorCommand::AgentResponse` via the command channel
|
||||
4. If `send_request()` only polls transport and response channels, the command channel isn't polled
|
||||
5. **Result**: Deadlock - agent waits for permission → permission stuck in channel → client waits for prompt response
|
||||
|
||||
**The Solution (src/connectors/acp/connector.rs:1253-1523):**
|
||||
|
||||
The `send_request()` method uses `tokio::select!` to poll **three** sources simultaneously:
|
||||
- **Response channel** (`response_rx`) - Waiting for the correlated JSON-RPC response
|
||||
- **Transport channel** - Receiving messages/notifications from agent (may trigger response)
|
||||
- **Command channel** (`cmd_rx`) - Receiving commands from event bridge (e.g., `AgentResponse`)
|
||||
|
||||
When an `AgentResponse` command arrives during `send_request()`:
|
||||
1. Extract the response payload from the command
|
||||
2. Send it to the agent via transport immediately
|
||||
3. Remove from pending requests map
|
||||
4. Continue waiting for the original prompt response
|
||||
|
||||
This pattern is **idiomatic async Rust** for implementing synchronous abstractions over bidirectional transports. It's similar to:
|
||||
- gRPC bidirectional streaming with request/response correlation
|
||||
- WebSocket clients with RPC-style method calls
|
||||
- HTTP/2 multiplexing with concurrent streams
|
||||
|
||||
**Why Not Separate Tasks?**
|
||||
|
||||
Alternative architectures (separate command processing task, full actor model) introduce:
|
||||
- Complex synchronization between tasks
|
||||
- Race conditions on shared state
|
||||
- Message ordering guarantees across channels
|
||||
- Significantly more code and cognitive overhead
|
||||
|
||||
The single-task, multi-channel select pattern keeps all state local and eliminates these issues.
|
||||
|
||||
**Key Invariant:**
|
||||
|
||||
Any method that blocks waiting for a response MUST also poll the command channel to process `AgentResponse` commands, otherwise bidirectional flows deadlock.
|
||||
|
||||
## Main Exports
|
||||
|
||||
### Runtime
|
||||
- `CoreRuntime` - Main orchestrator
|
||||
- `CoreHandle` - Cloneable runtime handle
|
||||
- `CoreConfig` - Runtime configuration
|
||||
|
||||
### Types
|
||||
- `ConnectorId`, `UserId` - Type aliases for IDs
|
||||
- `ConnectorKind` - Enum of connector types (OpenCode, Acp, Mock)
|
||||
- `ConnectorState` - Lifecycle state enum
|
||||
- `ConnectorSummary` - Lightweight connector view
|
||||
- `User` - User information
|
||||
|
||||
### Connectors
|
||||
- `Connector` - Trait for connector implementations
|
||||
- `ConnectorHandle` - Handle to a running connector
|
||||
- `ConnectorCommand` - Commands sent to connectors
|
||||
- `OpenCodeConnector` - OpenCode.ai integration
|
||||
- `OpenCodeConfig` - OpenCode connector configuration
|
||||
|
||||
### Configuration
|
||||
- `ConnectorConfig` - Configuration for creating connectors
|
||||
- `apply_template()` - Apply connector templates with patches
|
||||
|
||||
### ACP Protocol Types (Phases 3-7)
|
||||
- **Prompt Turn**:
|
||||
- `SessionPromptRequest`, `SessionPromptResponse` - Prompt requests/responses
|
||||
- `ContentBlock` - Text, Image, Audio, Resource, ResourceLink content
|
||||
- `EmbeddedResource` - Text or Blob embedded resources
|
||||
- `StopReason` - EndTurn, MaxTokens, MaxTurnRequests, Refusal, Cancelled
|
||||
- `PromptError` - Timeout, JsonRpcError, TransportError, Cancelled, ValidationError
|
||||
|
||||
- **Streaming Updates**:
|
||||
- `SessionUpdate` - All update types (agent_message_chunk, tool_call, plan, etc.)
|
||||
- `SessionUpdateNotification` - Notification wrapper with session_id
|
||||
- `ToolCallInfo` - Tool call tracking with status and content
|
||||
- `ToolKind` - Read, Edit, Search, Execute, Think, Other
|
||||
- `ToolCallStatus` - Pending, InProgress, Completed, Failed, Cancelled
|
||||
- `ToolCallContent` - Content, Diff, Terminal output
|
||||
- `MessageAccumulator` - Message chunk accumulation helper
|
||||
- `PlanEntry`, `Command` - Plan and command structures
|
||||
|
||||
- **Stop Reason Handling**:
|
||||
- `StopReasonAction` - Complete, ShowWarning, ShowError, ShowInfo
|
||||
- `handle_stop_reason()` - Interpret stop reasons
|
||||
- `is_continuable()`, `is_error()` - Stop reason classification
|
||||
|
||||
- **Cancellation**:
|
||||
- `handle_cancellation()` - Cancel pending operations
|
||||
- `cancel_pending_tool_calls()` - Mark tool calls as cancelled
|
||||
- `handle_disconnect()` - Handle transport disconnect
|
||||
|
||||
- **Error Classification**:
|
||||
- `ClassifiedError` - Error with class, message, details, retry_after
|
||||
- `ErrorClass` - Transient, Terminal, User
|
||||
- `ErrorSeverity` - Info, Warning, Error, Fatal
|
||||
- `ErrorAction` - Retry, Reconnect, CheckConfig, ContactSupport, Dismiss
|
||||
- `classify_jsonrpc_error()`, `classify_transport_error()` - Classify errors
|
||||
- `exponential_backoff()` - Calculate retry delays
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a Runtime
|
||||
|
||||
```rust
|
||||
use dirigent_core::{CoreRuntime, CoreConfig, CoreHandle};
|
||||
|
||||
// Load config from file or use default
|
||||
let config = CoreConfig::load_config(None)?;
|
||||
|
||||
// Create runtime
|
||||
let runtime = CoreRuntime::new(config);
|
||||
|
||||
// Wrap in handle for cheap cloning
|
||||
let handle = CoreHandle::new(runtime);
|
||||
```
|
||||
|
||||
### Creating a Connector
|
||||
|
||||
```rust
|
||||
use dirigent_core::{ConnectorConfig, ConnectorKind, OpenCodeConfig};
|
||||
use serde_json::json;
|
||||
|
||||
// Build OpenCode connector config
|
||||
let config = OpenCodeConfig {
|
||||
base_url: "http://localhost:12225".to_string(),
|
||||
title: "My OpenCode".to_string(),
|
||||
initial_session: None,
|
||||
};
|
||||
|
||||
// Serialize to JSON for ConnectorConfig
|
||||
let params = serde_json::to_value(&config)?;
|
||||
|
||||
let connector_config = ConnectorConfig {
|
||||
id: None, // Runtime generates ID
|
||||
kind: ConnectorKind::OpenCode,
|
||||
owner: Some("user-123".to_string()),
|
||||
title: Some("My OpenCode".to_string()),
|
||||
params,
|
||||
};
|
||||
|
||||
// Create connector via runtime
|
||||
let connector_id = handle.create_connector(
|
||||
"user-123".to_string(),
|
||||
connector_config
|
||||
).await?;
|
||||
```
|
||||
|
||||
### Using Templates
|
||||
|
||||
```rust
|
||||
use dirigent_core::{apply_template, ConnectorKind};
|
||||
use serde_json::json;
|
||||
|
||||
// Use default template with custom URL
|
||||
let connector_config = apply_template(
|
||||
ConnectorKind::OpenCode,
|
||||
"default",
|
||||
json!({
|
||||
"base_url": "http://localhost:8080",
|
||||
"title": "Custom OpenCode"
|
||||
})
|
||||
)?;
|
||||
|
||||
let connector_id = handle.create_connector(
|
||||
"user-123".to_string(),
|
||||
connector_config
|
||||
).await?;
|
||||
```
|
||||
|
||||
### Managing Connectors
|
||||
|
||||
```rust
|
||||
// List all connectors
|
||||
let all_connectors = handle.list_connectors(None).await;
|
||||
|
||||
// List connectors for a specific user
|
||||
let user_connectors = handle.list_connectors(Some("user-123".to_string())).await;
|
||||
|
||||
// Get a specific connector
|
||||
let connector = handle.get_connector(&connector_id).await;
|
||||
|
||||
// Stop a connector
|
||||
handle.stop_connector(&connector_id).await?;
|
||||
|
||||
// Restart a stopped connector
|
||||
handle.restart_connector(&connector_id).await?;
|
||||
|
||||
// Remove a connector
|
||||
handle.remove_connector(&connector_id).await?;
|
||||
```
|
||||
|
||||
### Connector Lifecycle with Restart
|
||||
|
||||
Connectors can be restarted after being stopped or entering an error state:
|
||||
|
||||
```rust
|
||||
// Create and start a connector
|
||||
let connector_id = handle.create_connector(
|
||||
"user-123".to_string(),
|
||||
connector_config
|
||||
).await?;
|
||||
// Connector is now in Ready state
|
||||
|
||||
// Stop the connector
|
||||
handle.stop_connector(&connector_id).await?;
|
||||
// Connector is now in Stopped state
|
||||
|
||||
// Restart the connector (recreates background task with fresh channels)
|
||||
handle.restart_connector(&connector_id).await?;
|
||||
// Connector transitions: Stopped → Initializing → Connecting → Ready
|
||||
|
||||
// Restart preserves:
|
||||
// - Connector ID (same instance)
|
||||
// - Configuration (base_url, title, etc.)
|
||||
// - Event broadcast channel (subscribers continue receiving)
|
||||
// - State Arc (observers see real-time updates)
|
||||
|
||||
// Restart recreates:
|
||||
// - Command channel (new sender/receiver pair)
|
||||
// - Connector instance (fresh OpenCodeConnector)
|
||||
// - Background task (new spawn)
|
||||
// - Task handle (new JoinHandle)
|
||||
```
|
||||
|
||||
### Sending Commands
|
||||
|
||||
```rust
|
||||
use dirigent_core::connectors::ConnectorCommand;
|
||||
|
||||
// Get connector handle
|
||||
let connector = handle.get_connector(&connector_id).await.unwrap();
|
||||
|
||||
// Subscribe to events
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Send a command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::ListSessions).await?;
|
||||
|
||||
// Receive events
|
||||
while let Ok(event) = events.recv().await {
|
||||
match event {
|
||||
Event::SessionsListed { sessions } => {
|
||||
println!("Got {} sessions", sessions.len());
|
||||
break;
|
||||
}
|
||||
Event::Error { message } => {
|
||||
eprintln!("Error: {}", message);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Global Event Stream
|
||||
|
||||
```rust
|
||||
// Subscribe to every event on the SharingBus. Callers can also pick an
|
||||
// `EventFilter` via `subscribe_filtered()` to receive only the events
|
||||
// they care about.
|
||||
let mut bus_rx = handle.sharing_bus().subscribe_all().await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(bus_event) = bus_rx.rx.recv().await {
|
||||
println!("Bus event: {:?}", bus_event.event);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Runtime Configuration (dirigent.toml or dirigent.json)
|
||||
|
||||
```toml
|
||||
port = 3000
|
||||
project_dir = "."
|
||||
project_name = "my_project"
|
||||
templates_enabled = true
|
||||
|
||||
[[connectors]]
|
||||
id = "opencode-1"
|
||||
kind = "OpenCode"
|
||||
owner = "user-123"
|
||||
title = "OpenCode Local"
|
||||
|
||||
[connectors.params]
|
||||
base_url = "http://localhost:12225"
|
||||
title = "OpenCode Local"
|
||||
initial_session = null
|
||||
```
|
||||
|
||||
### Templates
|
||||
|
||||
Available templates:
|
||||
- `opencode/default` - Standard localhost OpenCode connector
|
||||
- `acp/claude-default` - Claude API connector (stub for future)
|
||||
|
||||
## Connector Event Emission Patterns
|
||||
|
||||
### TurnComplete Event Semantics
|
||||
|
||||
All connectors emit `Event::TurnComplete` to signal that a turn/message is finalized. This is the **primary signal** for:
|
||||
- Archivist to finalize and write message to disk
|
||||
- UI cache to lock message state as immutable
|
||||
- Conductor bridge to flush response to upstream
|
||||
|
||||
**Event ordering guarantee**:
|
||||
```text
|
||||
MessageCompleted → TurnComplete → SessionIdle
|
||||
```
|
||||
|
||||
### Connector-Specific TurnComplete Strategies
|
||||
|
||||
Different connectors use different strategies to determine when a turn is complete:
|
||||
|
||||
#### OpenCode Connector
|
||||
- **Trigger**: `TurnCompleteTrigger::ExplicitSignal`
|
||||
- **Strategy**: Relies on upstream `session.idle` events from OpenCode.ai
|
||||
- **Implementation**: After translating `MessageCompleted`, emits `TurnComplete` then `SessionIdle`
|
||||
- **Code location**: `crates/dirigent_core/src/connectors/opencode.rs:550-575`
|
||||
|
||||
#### ACP Connector (stdio transport)
|
||||
- **Trigger**: `TurnCompleteTrigger::ResponseReceived`
|
||||
- **Strategy**: JSON-RPC response message is the final message in a turn
|
||||
- **Implementation**: Emits `TurnComplete` after receiving the JSON-RPC response to `session/prompt`
|
||||
- **Code location**: `crates/dirigent_core/src/connectors/acp/connector.rs:328`
|
||||
|
||||
#### Gateway Connector
|
||||
- **Trigger**: `TurnCompleteTrigger::OperationsComplete`
|
||||
- **Strategy**: Tracks pending tool calls and emits when all operations resolve
|
||||
- **Implementation**: Monitors tool call status changes and emits when last pending call completes
|
||||
- **Code location**: `crates/dirigent_core/src/connectors/gateway/mod.rs:464,556,626,678`
|
||||
|
||||
**Important**: Connectors MUST emit `TurnComplete` exactly once per turn. Duplicate emissions can cause archiving issues and UI state corruption.
|
||||
|
||||
### Gateway Session Transfer Mechanics
|
||||
|
||||
The Gateway connector serves as an **entry point** for incoming ACP connections. Sessions can be transferred to real agent connectors (Claude, etc.) via `/select-connector` commands.
|
||||
|
||||
#### Key Principle: New Connector Is Authority
|
||||
|
||||
**After transfer, the target connector becomes the sole authority for session configuration.**
|
||||
|
||||
```text
|
||||
Before transfer:
|
||||
Gateway has placeholder modes: "ask", "write", "yolo"
|
||||
Gateway has placeholder models: "simple", "default", "high"
|
||||
|
||||
After transfer to Claude:
|
||||
Claude's actual modes/models become authoritative
|
||||
Gateway's placeholders are irrelevant
|
||||
Editor receives config_option_update with Claude's real options
|
||||
```
|
||||
|
||||
#### Transfer Flow
|
||||
|
||||
1. User sends `/select-connector claude` in Gateway session
|
||||
2. Gateway emits `SessionTransferRequest` to CoreRuntime
|
||||
3. CoreRuntime creates/loads session in target connector
|
||||
4. Target connector emits `SessionCreated` with its modes/models
|
||||
5. CoreRuntime extracts modes/models from `SessionCreated` event
|
||||
6. CoreRuntime emits `SessionTransferred` event with modes/models
|
||||
7. Event bridge sends `config_option_update` to editor with target's modes/models
|
||||
|
||||
#### What Does NOT Happen
|
||||
|
||||
- Gateway does NOT adjust or map its values to the target connector
|
||||
- Gateway does NOT remain involved after transfer completes
|
||||
- Target connector does NOT inherit Gateway's mode/model selections
|
||||
- No "mapping" between Gateway placeholders and real connector values
|
||||
|
||||
#### Code Locations
|
||||
|
||||
- Transfer request handling: `src/runtime.rs:execute_transfer()`
|
||||
- Gateway commands: `src/connectors/gateway/commands.rs`
|
||||
- SessionTransferred event: `dirigent_protocol/src/events/mod.rs`
|
||||
- config_option_update emission: `dirigent_acp_api/src/event_bridge.rs:handle_session_transferred_internal()`
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Request-Response Pattern
|
||||
For operations that need results (list_sessions, list_messages):
|
||||
1. Subscribe to connector events
|
||||
2. Send command via command channel
|
||||
3. Wait for corresponding response event (with timeout)
|
||||
4. Return result or error
|
||||
|
||||
### Fire-and-Forget Pattern
|
||||
For operations that stream results (send_message):
|
||||
1. Send command via command channel
|
||||
2. Return immediately
|
||||
3. Clients subscribe to event stream for updates
|
||||
|
||||
### Lifecycle Management
|
||||
Connectors progress through states:
|
||||
1. **Initializing** - Created but not connecting
|
||||
2. **Connecting** - Attempting to establish connection
|
||||
3. **Ready** - Connected and operational
|
||||
4. **Error** - Encountered failure (with error message)
|
||||
5. **Stopped** - Shutdown or unrecoverable error
|
||||
|
||||
#### Restart Support
|
||||
Connectors in `Stopped` or `Error` state can be restarted:
|
||||
- **restart_connector()** recreates the connector's background task with fresh channels
|
||||
- Preserves connector identity (ID, owner, configuration)
|
||||
- Preserves event broadcast channel (existing subscribers continue receiving)
|
||||
- Recreates command channel and background task
|
||||
- State transitions: `Stopped`/`Error` → `Initializing` → `Connecting` → `Ready`
|
||||
|
||||
### Configuration Persistence
|
||||
The runtime automatically saves configuration to disk when:
|
||||
- A connector is created
|
||||
- A connector is removed
|
||||
- Configuration is explicitly saved
|
||||
|
||||
This ensures connectors are restored on server restart.
|
||||
|
||||
## Session Tracking Responsibilities
|
||||
|
||||
**IMPORTANT**: CoreRuntime is a **stateless orchestrator** for session operations. It does NOT maintain session state or cache message history.
|
||||
|
||||
### What CoreRuntime Does
|
||||
|
||||
- **Route Commands**: Forward session/message commands to appropriate connectors
|
||||
- **Broadcast Events**: Relay connector events to global event stream
|
||||
- **Manage Connectors**: Track which connectors are active and available
|
||||
- **Persist Configuration**: Save/load connector configuration (not session data)
|
||||
|
||||
### What CoreRuntime Does NOT Do
|
||||
|
||||
- **Cache Sessions**: Does not maintain lists of sessions or session metadata
|
||||
- **Store Messages**: Does not retain message content or history
|
||||
- **Buffer Events**: Does not cache events for replay or historical access
|
||||
- **Track Session State**: Does not know which sessions exist or their current state
|
||||
|
||||
### Stateless Design Rationale
|
||||
|
||||
1. **Multi-Connector Support**: With multiple connectors (OpenCode, ACP, etc.), caching sessions would require complex invalidation
|
||||
2. **Memory Efficiency**: Long-running server should not accumulate unbounded session history
|
||||
3. **Single Source of Truth**: External APIs (OpenCode.ai, ACP agents) are authoritative for session state
|
||||
4. **Scalability**: Stateless design supports future horizontal scaling
|
||||
|
||||
### Data Flow for Session Operations
|
||||
|
||||
**List Sessions**:
|
||||
```
|
||||
Server Function → CoreRuntime.get_connector() → Send Command → Connector queries API
|
||||
↓ ↓
|
||||
Returns handle Broadcasts SessionsListed
|
||||
↓
|
||||
Server function returns to UI
|
||||
(CoreRuntime does NOT cache list)
|
||||
```
|
||||
|
||||
**Send Message**:
|
||||
```
|
||||
Server Function → CoreRuntime.get_connector() → Send Command → Connector sends to API
|
||||
↓ ↓
|
||||
Returns handle Broadcasts MessageSent
|
||||
↓
|
||||
SSE pushes to UI in real-time
|
||||
(CoreRuntime does NOT store message)
|
||||
```
|
||||
|
||||
All session data passes through CoreRuntime but is **never cached**. See `docs/architecture/session_tracking.md` for the complete three-layer architecture (CoreRuntime, UI Cache, Archivist).
|
||||
|
||||
## Phase 4: `SharingBus` + `StreamRegistry` (2026-04-21)
|
||||
|
||||
Every `Event` emitted by a connector or the runtime is published onto a
|
||||
single `SharingBus` that owns:
|
||||
|
||||
- A `broadcast::Sender<BusEvent>` as the internal multicast.
|
||||
- A worker task that receives from that broadcast and dispatches per-
|
||||
subscriber `mpsc::Sender<BusEvent>` pipes with filter matching.
|
||||
- A `HashMap<(connector_id, native_session_id), scroll_id>` cache that
|
||||
late-binds `routing.scroll_id` for events emitted before their
|
||||
`SessionRegistered` event arrived.
|
||||
|
||||
### Subscriber model
|
||||
|
||||
`SharingBus::subscribe_all()` and `subscribe_filtered(EventFilter, cap)`
|
||||
return a `BusReceiver { id, rx, lagged }`. Filters are applied by the
|
||||
worker — subscribers never allocate a closure for skipped events.
|
||||
|
||||
`EventFilter` variants: `All | ScrollId | ConnectorUid | Kinds | AnyOf
|
||||
| AllOf`.
|
||||
|
||||
### Stream registry
|
||||
|
||||
The runtime owns a `StreamRegistry` and a `StreamFactoryRegistry`.
|
||||
Streams are attached via `CoreRuntime::attach_stream(StreamConfig)`:
|
||||
|
||||
- Factory registry resolves the `kind` string → concrete
|
||||
`Arc<dyn SessionStream>`.
|
||||
- The new stream gets its own `BusReceiver` scoped via `scope_to_filter`
|
||||
(Session → ScrollId, Connector → ConnectorUid, ArchiveWide → All).
|
||||
- A worker task pumps events into `stream.on_event(&bus_event).await`.
|
||||
|
||||
Health drift: `record_failure` / `record_success` in `sharing/health.rs`
|
||||
transitions `HealthStatus` on 5 consecutive failures
|
||||
(`Healthy → Degraded → Unavailable`).
|
||||
|
||||
### Replay
|
||||
|
||||
`CoreRuntime::replay_session_to_stream(scroll_id, stream_id, opts)`
|
||||
loads archived messages via the archivist's read API and calls
|
||||
`stream.on_event` directly, bypassing the bus. Each replayed event has
|
||||
`origin = EventOrigin::Replay { replay_id }` and
|
||||
`routing.scroll_id = Some(scroll_id)`.
|
||||
|
||||
### Config
|
||||
|
||||
`[[streams]]` blocks in `dirigent.toml` are parsed into `StreamsConfig`
|
||||
and applied at boot (best-effort: failures log + continue).
|
||||
|
||||
## Related Packages
|
||||
- **api** - Server functions that wrap CoreRuntime operations
|
||||
- **web** - Dioxus UI that calls server functions
|
||||
- **dirigent_protocol** - Shared event and message types
|
||||
- **opencode_client** - Low-level OpenCode.ai HTTP client (used by OpenCodeConnector)
|
||||
|
||||
## Documentation
|
||||
- README: ./README.md
|
||||
- Architecture: ../../docs/architecture/overview.md
|
||||
- Migration Guide: ../../docs/migration/singleton_to_runtime.md
|
||||
@@ -0,0 +1,120 @@
|
||||
[package]
|
||||
name = "dirigent_core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "dirigent-core"
|
||||
path = "src/bin/main.rs"
|
||||
required-features = ["server"]
|
||||
|
||||
[dependencies]
|
||||
# Server-only dependencies (optional)
|
||||
# ACP (Agent-Client Protocol) - the main dependency for connecting to agents
|
||||
# Using the rust-sdk from https://github.com/agentclientprotocol/rust-sdk
|
||||
agent-client-protocol = { version = "0.6", optional = true }
|
||||
# Async streams
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
# Async trait support
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
# Web server
|
||||
axum = { version = "0.8", optional = true }
|
||||
# Base64 encoding for embedded resources
|
||||
base64 = { version = "0.22", optional = true }
|
||||
# BLAKE3 hashing for stable URIs
|
||||
blake3 = { version = "1.5", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirigent_acp_api = { path = "../dirigent_acp_api", optional = true }
|
||||
# Workspace dependencies
|
||||
dirigent_archivist = { path = "../dirigent_archivist", optional = true }
|
||||
dirigent_config = { path = "../dirigent_config", optional = true }
|
||||
dirigent_auth = { path = "../dirigent_auth" }
|
||||
dirigent_process = { path = "../dirigent_process", features = ["tokio"], optional = true }
|
||||
dirigent_taskrunner = { path = "../dirigent_taskrunner", optional = true }
|
||||
dirigent_matrix = { path = "../dirigent_matrix", optional = true }
|
||||
dirigent_zed = { path = "../dirigent_zed", optional = true }
|
||||
dirigent_inspector = { path = "../dirigent_inspector", optional = true }
|
||||
dirigent_protocol = { path = "../dirigent_protocol", features = ["adapters"], optional = true }
|
||||
dirigent_tools = { path = "../dirigent_tools", optional = true }
|
||||
# SSE client for ACP transport
|
||||
eventsource-client = { version = "0.13", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
# Lazy static initialization
|
||||
once_cell = { version = "1.20", optional = true }
|
||||
opencode_client = { path = "../opencode_client", optional = true }
|
||||
# HTTP client for ACP transport
|
||||
reqwest = { version = "0.12", features = ["json", "stream"], optional = true }
|
||||
# Core types - always available (WASM-compatible)
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
# Error handling
|
||||
thiserror = { version = "2.0", optional = true }
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
toml = { version = "0.8", optional = true }
|
||||
tower = { version = "0.5", optional = true }
|
||||
tower-http = { version = "0.6", features = ["cors"], optional = true }
|
||||
# Logging
|
||||
tracing = { version = "0.1", optional = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
uuid = { version = "1.0", features = ["js", "serde", "v4", "v5", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
base64 = "0.22"
|
||||
blake3 = "1.5"
|
||||
dirigent_protocol = { path = "../dirigent_protocol" }
|
||||
dirigent_tools = { path = "../dirigent_tools" }
|
||||
tempfile = "3.0"
|
||||
# Test dependencies - mirror the optional server dependencies
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
toml = "0.8"
|
||||
|
||||
[[test]]
|
||||
name = "stream_registry_test"
|
||||
required-features = ["test-utils"]
|
||||
|
||||
[[test]]
|
||||
name = "replay_test"
|
||||
required-features = ["test-utils", "server"]
|
||||
|
||||
[[test]]
|
||||
name = "matrix_migration_test"
|
||||
required-features = ["server"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-utils = []
|
||||
server = [
|
||||
"dep:agent-client-protocol",
|
||||
"dep:async-stream",
|
||||
"dep:async-trait",
|
||||
"dep:axum",
|
||||
"dep:base64",
|
||||
"dep:blake3",
|
||||
"dep:dirigent_acp_api",
|
||||
"dep:dirigent_archivist",
|
||||
"dep:dirigent_config",
|
||||
"dep:dirigent_inspector",
|
||||
"dep:dirigent_protocol",
|
||||
"dep:dirigent_tools",
|
||||
"dep:eventsource-client",
|
||||
"dep:futures",
|
||||
"dep:once_cell",
|
||||
"dep:opencode_client",
|
||||
"dep:reqwest",
|
||||
"dep:thiserror",
|
||||
"dep:tokio",
|
||||
"dep:toml",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tracing",
|
||||
"dep:tracing-subscriber",
|
||||
"dep:dirigent_matrix",
|
||||
"dep:dirigent_zed",
|
||||
"dep:dirigent_taskrunner",
|
||||
"dep:dirigent_process",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
The Dirigent Core should implement the core functionality of Dirigent:
|
||||
|
||||
- setup and manage ACP Agents
|
||||
- connect as ACP client to ACP Agents
|
||||
- manage Projects
|
||||
- manage Sessions
|
||||
- manage file access, terminal access
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"port": 3000,
|
||||
"project_dir": ".",
|
||||
"project_name": "default",
|
||||
"connectors": [],
|
||||
"storage_dir": null,
|
||||
"templates_enabled": false,
|
||||
"archive_root": null
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//! ACP connector state management.
|
||||
//!
|
||||
//! This module defines the state structure for tracking ACP connection lifecycle,
|
||||
//! including protocol negotiation, authentication status, and capabilities.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Connection state for an ACP connector.
|
||||
///
|
||||
/// Tracks the lifecycle of an ACP connection from initial connection through
|
||||
/// initialization, optional authentication, and ready state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConnectionState {
|
||||
/// Not yet initialized (initial state).
|
||||
Uninitialized,
|
||||
/// Currently performing initialization handshake.
|
||||
Initializing,
|
||||
/// Initialization complete, ready for optional authentication.
|
||||
Initialized,
|
||||
/// Currently authenticating.
|
||||
Authenticating,
|
||||
/// Fully connected and ready for session operations.
|
||||
Ready,
|
||||
/// Connection lost or transport error.
|
||||
Disconnected,
|
||||
/// Terminal error state.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Complete ACP connector state.
|
||||
///
|
||||
/// This structure tracks all state related to an active ACP connection,
|
||||
/// including protocol version, capabilities, authentication status, and
|
||||
/// agent information.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcpConnectorState {
|
||||
/// Current connection state.
|
||||
pub connection_state: ConnectionState,
|
||||
|
||||
/// Negotiated protocol version (set after successful initialize).
|
||||
pub protocol_version: Option<u32>,
|
||||
|
||||
/// Client capabilities that were advertised to the agent.
|
||||
pub client_capabilities: Option<ClientCapabilities>,
|
||||
|
||||
/// Agent capabilities received from the agent.
|
||||
pub agent_capabilities: Option<AgentCapabilities>,
|
||||
|
||||
/// Agent implementation info (for display/logging).
|
||||
pub agent_info: Option<ImplementationInfo>,
|
||||
|
||||
/// Authentication methods supported by the agent.
|
||||
pub auth_methods: Vec<String>,
|
||||
|
||||
/// Whether authentication has been completed successfully.
|
||||
pub authenticated: bool,
|
||||
|
||||
/// Active sessions (keyed by session ID).
|
||||
///
|
||||
/// Tracks all sessions created through this connector. Each session
|
||||
/// maintains its own state including working directory, MCP servers,
|
||||
/// current mode, and cancellation status.
|
||||
pub sessions: HashMap<String, SessionState>,
|
||||
}
|
||||
|
||||
impl AcpConnectorState {
|
||||
/// Create a new uninitialized ACP connector state.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection_state: ConnectionState::Uninitialized,
|
||||
protocol_version: None,
|
||||
client_capabilities: None,
|
||||
agent_capabilities: None,
|
||||
agent_info: None,
|
||||
auth_methods: Vec::new(),
|
||||
authenticated: false,
|
||||
sessions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new session to the state.
|
||||
pub fn add_session(&mut self, session_state: SessionState) {
|
||||
self.sessions
|
||||
.insert(session_state.session_id.clone(), session_state);
|
||||
}
|
||||
|
||||
/// Get a session by ID.
|
||||
pub fn get_session(&self, session_id: &str) -> Option<&SessionState> {
|
||||
self.sessions.get(session_id)
|
||||
}
|
||||
|
||||
/// Get a mutable reference to a session by ID.
|
||||
pub fn get_session_mut(&mut self, session_id: &str) -> Option<&mut SessionState> {
|
||||
self.sessions.get_mut(session_id)
|
||||
}
|
||||
|
||||
/// Remove a session by ID.
|
||||
pub fn remove_session(&mut self, session_id: &str) -> Option<SessionState> {
|
||||
self.sessions.remove(session_id)
|
||||
}
|
||||
|
||||
/// Get all active session IDs.
|
||||
pub fn session_ids(&self) -> Vec<String> {
|
||||
self.sessions.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Check if the connector is ready for session operations.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
matches!(self.connection_state, ConnectionState::Ready)
|
||||
}
|
||||
|
||||
/// Check if authentication is required.
|
||||
pub fn requires_auth(&self) -> bool {
|
||||
!self.auth_methods.is_empty() && !self.authenticated
|
||||
}
|
||||
|
||||
/// Transition to error state with message.
|
||||
pub fn set_error(&mut self, message: impl Into<String>) {
|
||||
self.connection_state = ConnectionState::Error(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AcpConnectorState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared thread-safe wrapper for ACP connector state.
|
||||
pub type SharedAcpState = Arc<RwLock<AcpConnectorState>>;
|
||||
|
||||
/// Client capabilities advertised to the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ClientCapabilities {
|
||||
/// Filesystem capabilities.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fs: Option<FsCapabilities>,
|
||||
|
||||
/// Terminal/command execution support.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<bool>,
|
||||
|
||||
/// Extension metadata.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub _meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ClientCapabilities {
|
||||
/// Create default client capabilities (safe defaults: read-only filesystem, no terminal).
|
||||
pub fn default_safe() -> Self {
|
||||
Self {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create client capabilities with all features enabled.
|
||||
pub fn all_enabled() -> Self {
|
||||
Self {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(true),
|
||||
}),
|
||||
terminal: Some(true),
|
||||
_meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientCapabilities {
|
||||
/// Default capabilities: CURRENTLY DISABLED (handlers not implemented).
|
||||
///
|
||||
/// **IMPORTANT**: These capabilities are currently disabled because the request handlers
|
||||
/// in the ACP connector are not yet implemented. Advertising capabilities without
|
||||
/// functioning handlers violates ACP compliance and will cause agent requests to fail.
|
||||
///
|
||||
/// - **Filesystem Operations** (`fs`):
|
||||
/// - `read_text_file`: Disabled (handler not implemented)
|
||||
/// - `write_text_file`: Disabled (handler not implemented)
|
||||
///
|
||||
/// - **Terminal Operations** (`terminal`):
|
||||
/// - Disabled (handler not implemented)
|
||||
///
|
||||
/// **Future Implementation**: While the underlying tools exist in `packages/dirigent_tools/`,
|
||||
/// the ACP connector's request handler loop does not yet route agent requests to these
|
||||
/// tools. Once the handlers are implemented and tested, these capabilities can be enabled.
|
||||
///
|
||||
/// **Note**: Use `ClientCapabilities::all_enabled()` to override if you have implemented
|
||||
/// custom handlers, but this is NOT recommended until proper request handling is in place.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(false), // Not implemented yet
|
||||
write_text_file: Some(false), // Not implemented yet
|
||||
}),
|
||||
terminal: Some(false), // Not implemented yet
|
||||
_meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem capabilities.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FsCapabilities {
|
||||
/// Support for reading text files.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_text_file: Option<bool>,
|
||||
|
||||
/// Support for writing text files.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub write_text_file: Option<bool>,
|
||||
}
|
||||
|
||||
/// Agent capabilities received from the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct AgentCapabilities {
|
||||
/// Support for loading existing sessions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub load_session: Option<bool>,
|
||||
|
||||
/// Prompt-related capabilities.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_capabilities: Option<PromptCapabilities>,
|
||||
|
||||
/// MCP (Model Context Protocol) server support.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp: Option<McpCapabilities>,
|
||||
|
||||
/// Extension metadata.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub _meta: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl AgentCapabilities {
|
||||
/// Check if the agent supports loading sessions.
|
||||
pub fn supports_load_session(&self) -> bool {
|
||||
self.load_session.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if the agent supports image content in prompts.
|
||||
pub fn supports_image(&self) -> bool {
|
||||
self.prompt_capabilities
|
||||
.as_ref()
|
||||
.and_then(|pc| pc.image)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if the agent supports audio content in prompts.
|
||||
pub fn supports_audio(&self) -> bool {
|
||||
self.prompt_capabilities
|
||||
.as_ref()
|
||||
.and_then(|pc| pc.audio)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if the agent supports embedded context (resource blocks).
|
||||
pub fn supports_embedded_context(&self) -> bool {
|
||||
self.prompt_capabilities
|
||||
.as_ref()
|
||||
.and_then(|pc| pc.embedded_context)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt-related capabilities.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct PromptCapabilities {
|
||||
/// Support for image content blocks.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<bool>,
|
||||
|
||||
/// Support for audio content blocks.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audio: Option<bool>,
|
||||
|
||||
/// Support for embedded context (resource blocks).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub embedded_context: Option<bool>,
|
||||
}
|
||||
|
||||
/// MCP server capabilities.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct McpCapabilities {
|
||||
/// Support for HTTP transport MCP servers.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<bool>,
|
||||
|
||||
/// Support for SSE transport MCP servers.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sse: Option<bool>,
|
||||
}
|
||||
|
||||
/// Implementation information (for client or agent).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ImplementationInfo {
|
||||
/// Implementation name (e.g., "dirigent", "claude").
|
||||
pub name: String,
|
||||
|
||||
/// Human-readable title.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Version string (e.g., "0.1.0").
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Session state tracking.
|
||||
///
|
||||
/// Tracks the state of a single session including working directory,
|
||||
/// MCP servers, current mode, and cancellation status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionState {
|
||||
/// Session ID.
|
||||
pub session_id: String,
|
||||
/// Working directory.
|
||||
pub cwd: String,
|
||||
/// MCP servers configured for this session.
|
||||
pub mcp_servers: Vec<McpServer>,
|
||||
/// Current mode (e.g., "code", "chat"), if applicable.
|
||||
pub current_mode: Option<String>,
|
||||
/// Current model, if applicable.
|
||||
pub current_model: Option<String>,
|
||||
/// Whether a prompt is currently in progress.
|
||||
pub prompt_in_progress: bool,
|
||||
/// Whether cancellation is in progress.
|
||||
pub cancelling: bool,
|
||||
/// Whether this session is currently loading (replaying history).
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
/// Create a new session state.
|
||||
pub fn new(session_id: String, cwd: String, mcp_servers: Vec<McpServer>) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
cwd,
|
||||
mcp_servers,
|
||||
current_mode: None,
|
||||
current_model: None,
|
||||
prompt_in_progress: false,
|
||||
cancelling: false,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the session is ready for new prompts.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
!self.prompt_in_progress && !self.cancelling && !self.loading
|
||||
}
|
||||
|
||||
/// Mark a prompt as started.
|
||||
pub fn start_prompt(&mut self) {
|
||||
self.prompt_in_progress = true;
|
||||
}
|
||||
|
||||
/// Mark a prompt as completed.
|
||||
pub fn complete_prompt(&mut self) {
|
||||
self.prompt_in_progress = false;
|
||||
self.cancelling = false;
|
||||
}
|
||||
|
||||
/// Initiate cancellation.
|
||||
pub fn start_cancellation(&mut self) {
|
||||
self.cancelling = true;
|
||||
}
|
||||
|
||||
/// Mark the session as loading (history replay).
|
||||
pub fn start_loading(&mut self) {
|
||||
self.loading = true;
|
||||
}
|
||||
|
||||
/// Mark loading as complete.
|
||||
pub fn complete_loading(&mut self) {
|
||||
self.loading = false;
|
||||
}
|
||||
|
||||
/// Update the current mode.
|
||||
pub fn set_mode(&mut self, mode: String) {
|
||||
self.current_mode = Some(mode);
|
||||
}
|
||||
|
||||
/// Update the current model.
|
||||
pub fn set_model(&mut self, model: String) {
|
||||
self.current_model = Some(model);
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP (Model Context Protocol) server configuration.
|
||||
///
|
||||
/// Specifies how to connect to an MCP server that provides additional
|
||||
/// tools and context to the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum McpServer {
|
||||
/// Stdio transport - spawn a process and communicate via stdin/stdout.
|
||||
Stdio {
|
||||
/// Name of the MCP server (for display).
|
||||
name: String,
|
||||
/// Command to execute.
|
||||
command: String,
|
||||
/// Command-line arguments.
|
||||
args: Vec<String>,
|
||||
/// Environment variables.
|
||||
env: Vec<EnvVariable>,
|
||||
},
|
||||
/// HTTP transport - connect to an HTTP endpoint.
|
||||
Http {
|
||||
/// Name of the MCP server (for display).
|
||||
name: String,
|
||||
/// Base URL of the HTTP endpoint.
|
||||
url: String,
|
||||
/// HTTP headers to include in requests.
|
||||
headers: Vec<HttpHeader>,
|
||||
},
|
||||
/// SSE (Server-Sent Events) transport - connect to an SSE endpoint.
|
||||
Sse {
|
||||
/// Name of the MCP server (for display).
|
||||
name: String,
|
||||
/// URL of the SSE endpoint.
|
||||
url: String,
|
||||
/// HTTP headers to include in connection.
|
||||
headers: Vec<HttpHeader>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Environment variable for MCP server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct EnvVariable {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// HTTP header for MCP server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HttpHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connection_state_transitions() {
|
||||
let mut state = AcpConnectorState::new();
|
||||
assert_eq!(state.connection_state, ConnectionState::Uninitialized);
|
||||
assert!(!state.is_ready());
|
||||
|
||||
state.connection_state = ConnectionState::Initializing;
|
||||
assert!(!state.is_ready());
|
||||
|
||||
state.connection_state = ConnectionState::Initialized;
|
||||
assert!(!state.is_ready());
|
||||
|
||||
state.connection_state = ConnectionState::Ready;
|
||||
assert!(state.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_auth() {
|
||||
let mut state = AcpConnectorState::new();
|
||||
assert!(!state.requires_auth());
|
||||
|
||||
state.auth_methods = vec!["api_key".to_string()];
|
||||
assert!(state.requires_auth());
|
||||
|
||||
state.authenticated = true;
|
||||
assert!(!state.requires_auth());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_capabilities_default_safe() {
|
||||
let caps = ClientCapabilities::default_safe();
|
||||
assert!(caps.fs.is_some());
|
||||
assert_eq!(caps.fs.as_ref().unwrap().read_text_file, Some(true));
|
||||
assert_eq!(caps.fs.as_ref().unwrap().write_text_file, Some(false));
|
||||
assert_eq!(caps.terminal, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_capabilities_all_enabled() {
|
||||
let caps = ClientCapabilities::all_enabled();
|
||||
assert!(caps.fs.is_some());
|
||||
assert_eq!(caps.fs.as_ref().unwrap().read_text_file, Some(true));
|
||||
assert_eq!(caps.fs.as_ref().unwrap().write_text_file, Some(true));
|
||||
assert_eq!(caps.terminal, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_capabilities_checks() {
|
||||
let caps = AgentCapabilities {
|
||||
load_session: Some(true),
|
||||
prompt_capabilities: Some(PromptCapabilities {
|
||||
image: Some(true),
|
||||
audio: Some(false),
|
||||
embedded_context: Some(true),
|
||||
}),
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
assert!(caps.supports_load_session());
|
||||
assert!(caps.supports_image());
|
||||
assert!(!caps.supports_audio());
|
||||
assert!(caps.supports_embedded_context());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(true),
|
||||
}),
|
||||
terminal: Some(true),
|
||||
_meta: Some(serde_json::json!({"custom": "data"})),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&caps).unwrap();
|
||||
let deserialized: ClientCapabilities = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(caps, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implementation_info() {
|
||||
let info = ImplementationInfo {
|
||||
name: "test-impl".to_string(),
|
||||
title: Some("Test Implementation".to_string()),
|
||||
version: Some("1.0.0".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
let deserialized: ImplementationInfo = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(info, deserialized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Content block generation from file attachments.
|
||||
//!
|
||||
//! This module provides the `ContentBlockBuilder` for converting file paths into
|
||||
//! appropriate `ContentBlock` variants based on:
|
||||
//! - Agent capabilities (embedded context support)
|
||||
//! - File properties (size, type, content)
|
||||
//! - Embedding configuration (size limits, redaction, snippet strategy)
|
||||
//! - Sandbox restrictions (allowed roots, blocklists)
|
||||
|
||||
use crate::acp::connector_state::AgentCapabilities;
|
||||
use crate::acp::protocol::prompt::{Annotations, ContentBlock, EmbeddedResource};
|
||||
use base64::Engine;
|
||||
use dirigent_tools::config::{EmbeddingConfig, SandboxConfig};
|
||||
use dirigent_tools::embedding::{EmbeddingDecider, EmbeddingStrategy};
|
||||
use dirigent_tools::error::{ToolError, ToolResult};
|
||||
use dirigent_tools::path::validate_path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Builder for generating content blocks from file attachments.
|
||||
///
|
||||
/// This orchestrates the file embedding pipeline:
|
||||
/// 1. Validate paths against sandbox
|
||||
/// 2. Decide embedding strategy per file
|
||||
/// 3. Apply redaction to embedded content
|
||||
/// 4. Generate appropriate ContentBlock variants
|
||||
/// 5. Track accumulated bytes and file counts
|
||||
pub struct ContentBlockBuilder {
|
||||
/// Agent capabilities (embedded context support).
|
||||
// TODO: Capability validation - will be used to validate content blocks against agent capabilities
|
||||
#[allow(dead_code)]
|
||||
agent_caps: AgentCapabilities,
|
||||
/// Embedding configuration.
|
||||
embedding_config: EmbeddingConfig,
|
||||
/// Sandbox configuration.
|
||||
sandbox_config: SandboxConfig,
|
||||
/// Embedding decider (tracks accumulated state).
|
||||
decider: EmbeddingDecider,
|
||||
}
|
||||
|
||||
impl ContentBlockBuilder {
|
||||
/// Create a new content block builder.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `agent_caps` - Agent capabilities (from Initialize response)
|
||||
/// * `embedding_config` - Embedding configuration
|
||||
/// * `sandbox_config` - Sandbox configuration
|
||||
pub fn new(
|
||||
agent_caps: AgentCapabilities,
|
||||
embedding_config: EmbeddingConfig,
|
||||
sandbox_config: SandboxConfig,
|
||||
) -> Self {
|
||||
let agent_supports_embedded = agent_caps.supports_embedded_context();
|
||||
let decider = EmbeddingDecider::new(embedding_config.clone(), agent_supports_embedded);
|
||||
|
||||
Self {
|
||||
agent_caps,
|
||||
embedding_config,
|
||||
sandbox_config,
|
||||
decider,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build content blocks from a list of file paths.
|
||||
///
|
||||
/// This is the main entry point for converting file attachments into content blocks.
|
||||
/// It validates each path, decides the embedding strategy, and generates the
|
||||
/// appropriate ContentBlock variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `files` - List of file paths to process (must be absolute)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of `ContentBlock` variants, or an error if any path is invalid
|
||||
/// or if the embedding pipeline fails.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `ToolError::SandboxViolation` - Path outside allowed roots or blocked
|
||||
/// - `ToolError::InvalidPath` - Path doesn't exist or is not a file
|
||||
/// - `ToolError::FileReadError` - Failed to read file content
|
||||
///
|
||||
/// # Security
|
||||
///
|
||||
/// All paths are validated against the sandbox before processing. Absolute
|
||||
/// paths are never exposed in error messages.
|
||||
pub fn build_from_files(&mut self, files: &[PathBuf]) -> ToolResult<Vec<ContentBlock>> {
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
for file_path in files {
|
||||
// Validate path against sandbox first
|
||||
let validated_path = self.validate_file_path(file_path)?;
|
||||
|
||||
// Decide embedding strategy
|
||||
let strategy = self.decider.decide(&validated_path)?;
|
||||
|
||||
// Generate content block based on strategy
|
||||
match self.generate_content_block(&validated_path, strategy)? {
|
||||
Some(block) => blocks.push(block),
|
||||
None => {
|
||||
// Strategy was Deny - log and skip
|
||||
let filename = validated_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
warn!("File denied for embedding: {}", filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Generated {} content blocks from {} files ({} bytes accumulated)",
|
||||
blocks.len(),
|
||||
files.len(),
|
||||
self.decider.accumulated_bytes()
|
||||
);
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// Validate a file path against sandbox configuration.
|
||||
///
|
||||
/// This ensures the path:
|
||||
/// - Is within allowed roots
|
||||
/// - Is not in blocklist
|
||||
/// - Exists and is a file
|
||||
///
|
||||
/// # Security
|
||||
///
|
||||
/// Error messages never expose absolute paths - only basenames.
|
||||
fn validate_file_path(&self, path: &Path) -> ToolResult<PathBuf> {
|
||||
// Convert to string for validation
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| ToolError::InvalidConfig(
|
||||
format!("Path contains invalid UTF-8: {}", path.display())
|
||||
))?;
|
||||
|
||||
// Validate against sandbox
|
||||
let canonical_path = validate_path(path_str, &self.sandbox_config)?;
|
||||
|
||||
// Ensure it's a file, not a directory
|
||||
if !canonical_path.is_file() {
|
||||
let filename = canonical_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
return Err(ToolError::FileReadError {
|
||||
path: filename.to_string(),
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Not a file"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(canonical_path)
|
||||
}
|
||||
|
||||
/// Generate a content block from a validated path and strategy.
|
||||
///
|
||||
/// Returns `None` if the strategy is `Deny`.
|
||||
fn generate_content_block(
|
||||
&self,
|
||||
path: &Path,
|
||||
strategy: EmbeddingStrategy,
|
||||
) -> ToolResult<Option<ContentBlock>> {
|
||||
match strategy {
|
||||
EmbeddingStrategy::EmbedText { content, mime_type } => {
|
||||
// Apply redaction if configured
|
||||
let redacted_content = self.apply_redaction(&content);
|
||||
|
||||
// Generate URI for the resource
|
||||
let uri = self.generate_uri(path);
|
||||
|
||||
// Create embedded resource
|
||||
let resource = EmbeddedResource::Text {
|
||||
uri: uri.clone(),
|
||||
text: redacted_content,
|
||||
mime_type: Some(mime_type),
|
||||
};
|
||||
|
||||
// Create annotations (mark as assistant-focused)
|
||||
let annotations = Some(Annotations {
|
||||
audience: Some(vec!["assistant".to_string()]),
|
||||
priority: Some(0.5),
|
||||
});
|
||||
|
||||
Ok(Some(ContentBlock::Resource {
|
||||
resource,
|
||||
annotations,
|
||||
}))
|
||||
}
|
||||
|
||||
EmbeddingStrategy::EmbedBlob { data, mime_type } => {
|
||||
// Encode as base64
|
||||
let blob = base64::engine::general_purpose::STANDARD.encode(&data);
|
||||
|
||||
// Generate URI
|
||||
let uri = self.generate_uri(path);
|
||||
|
||||
// Create embedded resource
|
||||
let resource = EmbeddedResource::Blob {
|
||||
uri: uri.clone(),
|
||||
blob,
|
||||
mime_type: Some(mime_type),
|
||||
};
|
||||
|
||||
// Create annotations
|
||||
let annotations = Some(Annotations {
|
||||
audience: Some(vec!["assistant".to_string()]),
|
||||
priority: Some(0.5),
|
||||
});
|
||||
|
||||
Ok(Some(ContentBlock::Resource {
|
||||
resource,
|
||||
annotations,
|
||||
}))
|
||||
}
|
||||
|
||||
EmbeddingStrategy::Link {
|
||||
uri,
|
||||
name,
|
||||
size,
|
||||
mime_type,
|
||||
} => {
|
||||
// Create resource link
|
||||
let annotations = Some(Annotations {
|
||||
audience: Some(vec!["assistant".to_string()]),
|
||||
priority: Some(0.3),
|
||||
});
|
||||
|
||||
Ok(Some(ContentBlock::ResourceLink {
|
||||
uri,
|
||||
name,
|
||||
mime_type,
|
||||
title: None,
|
||||
description: None,
|
||||
size: Some(size),
|
||||
annotations,
|
||||
}))
|
||||
}
|
||||
|
||||
EmbeddingStrategy::Snippet {
|
||||
head,
|
||||
tail,
|
||||
total_size: _,
|
||||
mime_type,
|
||||
} => {
|
||||
// For snippets, create both a resource (embedded snippet) and a link (full file)
|
||||
// Combine head and tail with separator
|
||||
let separator = "\n\n[... truncated ...]\n\n";
|
||||
let snippet_content = format!("{}{}{}", head, separator, tail);
|
||||
|
||||
// Apply redaction to snippet
|
||||
let redacted_snippet = self.apply_redaction(&snippet_content);
|
||||
|
||||
// Generate URI
|
||||
let uri = self.generate_uri(path);
|
||||
|
||||
// Create embedded resource for snippet
|
||||
let resource = EmbeddedResource::Text {
|
||||
uri: uri.clone(),
|
||||
text: redacted_snippet,
|
||||
mime_type: Some(mime_type.clone()),
|
||||
};
|
||||
|
||||
// Create annotations for snippet
|
||||
let annotations = Some(Annotations {
|
||||
audience: Some(vec!["assistant".to_string()]),
|
||||
priority: Some(0.4),
|
||||
});
|
||||
|
||||
// Return the snippet as embedded resource
|
||||
// Note: Could optionally also include a ResourceLink to the full file
|
||||
Ok(Some(ContentBlock::Resource {
|
||||
resource,
|
||||
annotations,
|
||||
}))
|
||||
}
|
||||
|
||||
EmbeddingStrategy::Deny { reason } => {
|
||||
// Log denial reason
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown");
|
||||
warn!("File embedding denied for {}: {}", filename, reason);
|
||||
|
||||
// Return None to skip this file
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a stable URI for a file path.
|
||||
///
|
||||
/// Uses BLAKE3 hash of the canonical path for stability and opacity.
|
||||
fn generate_uri(&self, path: &Path) -> String {
|
||||
use blake3::Hasher;
|
||||
|
||||
let mut hasher = Hasher::new();
|
||||
hasher.update(path.to_string_lossy().as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
|
||||
format!("dirigent://resource/{}", hash.to_hex())
|
||||
}
|
||||
|
||||
/// Apply redaction patterns to content.
|
||||
///
|
||||
/// This is a best-effort operation that doesn't modify files on disk.
|
||||
fn apply_redaction(&self, content: &str) -> String {
|
||||
if self.embedding_config.redact_patterns.is_empty() {
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
// Use ContentRedactor from dirigent_tools
|
||||
match dirigent_tools::embedding::ContentRedactor::new(&self.embedding_config.redact_patterns)
|
||||
{
|
||||
Ok(redactor) => redactor.redact(content),
|
||||
Err(e) => {
|
||||
warn!("Failed to create redactor: {}", e);
|
||||
content.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the accumulated bytes processed so far.
|
||||
pub fn accumulated_bytes(&self) -> usize {
|
||||
self.decider.accumulated_bytes()
|
||||
}
|
||||
|
||||
/// Get the number of files processed so far.
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.decider.file_count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function for building content blocks from files.
|
||||
///
|
||||
/// This is a simplified API for one-off conversions.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `files` - List of file paths to convert (must be absolute)
|
||||
/// * `agent_caps` - Agent capabilities
|
||||
/// * `embedding_config` - Embedding configuration
|
||||
/// * `sandbox_config` - Sandbox configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of `ContentBlock` variants.
|
||||
pub fn build_content_blocks_from_files(
|
||||
files: &[PathBuf],
|
||||
agent_caps: &AgentCapabilities,
|
||||
embedding_config: &EmbeddingConfig,
|
||||
sandbox_config: &SandboxConfig,
|
||||
) -> ToolResult<Vec<ContentBlock>> {
|
||||
let mut builder = ContentBlockBuilder::new(
|
||||
agent_caps.clone(),
|
||||
embedding_config.clone(),
|
||||
sandbox_config.clone(),
|
||||
);
|
||||
|
||||
builder.build_from_files(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::connector_state::PromptCapabilities;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_agent_caps(embedded_context: bool) -> AgentCapabilities {
|
||||
AgentCapabilities {
|
||||
prompt_capabilities: Some(PromptCapabilities {
|
||||
embedded_context: Some(embedded_context),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_config(temp_dir: &TempDir) -> (EmbeddingConfig, SandboxConfig) {
|
||||
let embedding_config = EmbeddingConfig {
|
||||
max_embed_bytes: 1000,
|
||||
allow_resource_link: true,
|
||||
redact_patterns: vec![],
|
||||
snippet_strategy: dirigent_tools::config::SnippetStrategy::HeadTail,
|
||||
max_files_per_prompt: 10,
|
||||
};
|
||||
|
||||
let mut sandbox_config = SandboxConfig::default();
|
||||
sandbox_config.allowed_roots = vec![temp_dir.path().to_path_buf()];
|
||||
sandbox_config.normalize_roots();
|
||||
|
||||
(embedding_config, sandbox_config)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_text_file_embeds() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "Hello, world!").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
match &blocks[0] {
|
||||
ContentBlock::Resource { resource, .. } => match resource {
|
||||
EmbeddedResource::Text { text, .. } => {
|
||||
assert_eq!(text, "Hello, world!");
|
||||
}
|
||||
_ => panic!("Expected text resource"),
|
||||
},
|
||||
_ => panic!("Expected resource block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_file_creates_link() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("large.txt");
|
||||
let large_content = "x".repeat(2000); // Exceeds 1000 byte limit
|
||||
std::fs::write(&file_path, &large_content).unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
match &blocks[0] {
|
||||
ContentBlock::ResourceLink { size, .. } => {
|
||||
assert_eq!(*size, Some(2000));
|
||||
}
|
||||
_ => panic!("Expected resource link block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_violation_rejected() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let outside_dir = TempDir::new().unwrap();
|
||||
let file_path = outside_dir.path().join("outside.txt");
|
||||
std::fs::write(&file_path, "Outside sandbox").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let result = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_files_accumulate_bytes() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
let file1 = temp_dir.path().join("file1.txt");
|
||||
std::fs::write(&file1, "File 1 content").unwrap();
|
||||
|
||||
let file2 = temp_dir.path().join("file2.txt");
|
||||
std::fs::write(&file2, "File 2 content").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file1, file2],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uri_generation_is_stable() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("stable.txt");
|
||||
std::fs::write(&file_path, "Stable content").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks1 = build_content_blocks_from_files(
|
||||
&[file_path.clone()],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let blocks2 = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Extract URIs and compare
|
||||
match (&blocks1[0], &blocks2[0]) {
|
||||
(
|
||||
ContentBlock::Resource {
|
||||
resource: EmbeddedResource::Text { uri: uri1, .. },
|
||||
..
|
||||
},
|
||||
ContentBlock::Resource {
|
||||
resource: EmbeddedResource::Text { uri: uri2, .. },
|
||||
..
|
||||
},
|
||||
) => {
|
||||
assert_eq!(uri1, uri2, "URIs should be stable");
|
||||
}
|
||||
_ => panic!("Expected resource blocks"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! ACP (Agent-Client Protocol) client implementation.
|
||||
//!
|
||||
//! This module provides a complete ACP client implementation, including:
|
||||
//! - Transport layer (stdio and HTTP+SSE)
|
||||
//! - Protocol implementation (initialization, sessions, prompts)
|
||||
//! - Tool execution and permission handling
|
||||
//! - Streaming updates and notifications
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The ACP implementation is organized into layers:
|
||||
//!
|
||||
//! 1. **Transport Layer** (`transport/`) - JSON-RPC types and shared utilities
|
||||
//! - JSON-RPC message types (request, response, notification, error)
|
||||
//! - `JsonLineReader` for resilient multi-line JSON reading over stdio
|
||||
//! - Transport trait definition
|
||||
//! - Actual transport implementations live in `connectors::acp::transport/`
|
||||
//!
|
||||
//! 2. **Protocol Layer** (`protocol/`) - ACP message handling
|
||||
//! - Initialization and capability negotiation
|
||||
//! - Authentication (optional)
|
||||
//! - Capability validation
|
||||
//! - Session lifecycle (new, load, cancel)
|
||||
//! - Prompt turns and streaming updates
|
||||
//! - Error handling and classification
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use dirigent_core::connectors::acp::transport::{AcpTransport, StdioTransport};
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let mut transport = StdioTransport::new("claude", &["--acp"]);
|
||||
//! transport.connect().await?;
|
||||
//!
|
||||
//! // Send and receive JSON-RPC messages
|
||||
//! transport.send(serde_json::json!({
|
||||
//! "jsonrpc": "2.0",
|
||||
//! "method": "initialize",
|
||||
//! "id": 1,
|
||||
//! "params": {}
|
||||
//! })).await?;
|
||||
//!
|
||||
//! if let Some(response) = transport.recv().await? {
|
||||
//! println!("Response: {:?}", response);
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod connector_state;
|
||||
pub mod content_blocks;
|
||||
pub mod protocol;
|
||||
pub mod transport;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use connector_state::{
|
||||
AcpConnectorState, AgentCapabilities, ClientCapabilities, ConnectionState, EnvVariable,
|
||||
FsCapabilities, HttpHeader, ImplementationInfo, McpCapabilities, McpServer, PromptCapabilities,
|
||||
SessionState, SharedAcpState,
|
||||
};
|
||||
pub use content_blocks::{build_content_blocks_from_files, ContentBlockBuilder};
|
||||
pub use protocol::{authenticate, capabilities, initialize};
|
||||
pub use transport::{
|
||||
JsonRpcError, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, JsonRpcResult, Transport,
|
||||
TransportError, TransportState,
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Authentication method implementation for ACP.
|
||||
//!
|
||||
//! This module implements the `authenticate` JSON-RPC method for agents that
|
||||
//! require authentication after initialization. The authentication flow is optional
|
||||
//! and only used if the agent advertises auth_methods in the initialize response.
|
||||
|
||||
use crate::acp::transport::{JsonRpcRequest, JsonRpcResult, Transport, TransportError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Authentication request sent to the agent.
|
||||
///
|
||||
/// This is sent after initialization if the agent advertised auth_methods
|
||||
/// and the client has credentials available.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AuthenticateRequest {
|
||||
/// Authentication method to use (must be one of the methods advertised by agent).
|
||||
pub method: String,
|
||||
|
||||
/// Method-specific credentials (structure depends on the authentication method).
|
||||
/// Common examples:
|
||||
/// - API key: `{"api_key": "secret"}`
|
||||
/// - OAuth: `{"token": "bearer_token"}`
|
||||
pub credentials: serde_json::Value,
|
||||
}
|
||||
|
||||
impl AuthenticateRequest {
|
||||
/// Create a new authentication request.
|
||||
pub fn new(method: impl Into<String>, credentials: serde_json::Value) -> Self {
|
||||
Self {
|
||||
method: method.into(),
|
||||
credentials,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC request.
|
||||
pub fn to_jsonrpc(&self, id: u64) -> JsonRpcRequest {
|
||||
JsonRpcRequest::new(
|
||||
id,
|
||||
"authenticate",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize AuthenticateRequest")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Debug implementation that redacts credentials (no derive(Debug) above)
|
||||
impl std::fmt::Debug for AuthenticateRequest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthenticateRequest")
|
||||
.field("method", &self.method)
|
||||
.field("credentials", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication response from the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AuthenticateResponse {
|
||||
/// Whether authentication succeeded.
|
||||
pub success: bool,
|
||||
|
||||
/// Error message if authentication failed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthenticateResponse {
|
||||
/// Parse from JSON-RPC result.
|
||||
pub fn from_jsonrpc(result: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_value(result.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of authentication attempt.
|
||||
#[derive(Debug)]
|
||||
pub enum AuthenticateResult {
|
||||
/// Authentication succeeded.
|
||||
Success,
|
||||
/// Authentication failed with error message.
|
||||
Failed(String),
|
||||
/// Agent does not require authentication.
|
||||
NotRequired,
|
||||
}
|
||||
|
||||
/// Perform authentication with the agent.
|
||||
///
|
||||
/// This sends an authenticate request if the agent requires authentication.
|
||||
/// If auth_methods is empty, returns NotRequired without sending a request.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `auth_methods` - Authentication methods advertised by the agent (from initialize response)
|
||||
/// * `method` - Authentication method to use (must be in auth_methods)
|
||||
/// * `credentials` - Method-specific credentials
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Result of the authentication attempt.
|
||||
///
|
||||
/// # Security
|
||||
///
|
||||
/// Credentials are never logged in plain text. The AuthenticateRequest Debug
|
||||
/// implementation redacts credentials.
|
||||
pub async fn authenticate(
|
||||
transport: &mut dyn Transport,
|
||||
auth_methods: &[String],
|
||||
method: impl Into<String>,
|
||||
credentials: serde_json::Value,
|
||||
) -> Result<AuthenticateResult, TransportError> {
|
||||
let method_str = method.into();
|
||||
|
||||
// Check if authentication is required
|
||||
if auth_methods.is_empty() {
|
||||
return Ok(AuthenticateResult::NotRequired);
|
||||
}
|
||||
|
||||
// Validate that the requested method is supported
|
||||
if !auth_methods.contains(&method_str) {
|
||||
return Ok(AuthenticateResult::Failed(format!(
|
||||
"Authentication method '{}' not supported by agent. Supported methods: {:?}",
|
||||
method_str, auth_methods
|
||||
)));
|
||||
}
|
||||
|
||||
// Create authenticate request
|
||||
let request = AuthenticateRequest::new(method_str, credentials);
|
||||
let jsonrpc_request = request.to_jsonrpc(2); // Use ID 2 (ID 1 is for initialize)
|
||||
|
||||
// Send request and await response
|
||||
let result = transport.send_request(jsonrpc_request).await?;
|
||||
|
||||
// Handle response
|
||||
match result {
|
||||
JsonRpcResult::Success(response) => {
|
||||
// Parse authenticate response
|
||||
let auth_response = AuthenticateResponse::from_jsonrpc(&response.result)
|
||||
.map_err(|e| TransportError::SerializationError(e))?;
|
||||
|
||||
if auth_response.success {
|
||||
Ok(AuthenticateResult::Success)
|
||||
} else {
|
||||
Ok(AuthenticateResult::Failed(
|
||||
auth_response
|
||||
.error
|
||||
.unwrap_or_else(|| "Authentication failed".to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
JsonRpcResult::Error(error_response) => {
|
||||
// Authentication failed with JSON-RPC error
|
||||
Ok(AuthenticateResult::Failed(error_response.error.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if authentication is required based on agent's auth_methods.
|
||||
pub fn is_auth_required(auth_methods: &[String]) -> bool {
|
||||
!auth_methods.is_empty()
|
||||
}
|
||||
|
||||
/// Validate that credentials do not appear in log output.
|
||||
///
|
||||
/// This is a utility function for testing that ensures credentials are properly
|
||||
/// redacted in debug output.
|
||||
#[cfg(test)]
|
||||
pub fn ensure_credentials_redacted(debug_str: &str) -> bool {
|
||||
!debug_str.contains("secret") && !debug_str.contains("password") && !debug_str.contains("token")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_request_creation() {
|
||||
let creds = serde_json::json!({"api_key": "secret123"});
|
||||
let request = AuthenticateRequest::new("api_key", creds.clone());
|
||||
|
||||
assert_eq!(request.method, "api_key");
|
||||
assert_eq!(request.credentials, creds);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_request_redacts_credentials() {
|
||||
let creds = serde_json::json!({"api_key": "secret123"});
|
||||
let request = AuthenticateRequest::new("api_key", creds);
|
||||
|
||||
let debug_str = format!("{:?}", request);
|
||||
|
||||
// Should contain method
|
||||
assert!(debug_str.contains("api_key"));
|
||||
// Should NOT contain credentials
|
||||
assert!(!debug_str.contains("secret123"));
|
||||
// Should show redacted marker
|
||||
assert!(debug_str.contains("REDACTED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_request_to_jsonrpc() {
|
||||
let creds = serde_json::json!({"api_key": "secret"});
|
||||
let request = AuthenticateRequest::new("api_key", creds);
|
||||
|
||||
let jsonrpc = request.to_jsonrpc(42);
|
||||
|
||||
assert_eq!(jsonrpc.method, "authenticate");
|
||||
assert_eq!(jsonrpc.id, serde_json::Value::Number(42.into()));
|
||||
assert!(jsonrpc.params.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_response_success() {
|
||||
let json = serde_json::json!({
|
||||
"success": true
|
||||
});
|
||||
|
||||
let response = AuthenticateResponse::from_jsonrpc(&json).unwrap();
|
||||
|
||||
assert!(response.success);
|
||||
assert!(response.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_response_failure() {
|
||||
let json = serde_json::json!({
|
||||
"success": false,
|
||||
"error": "Invalid API key"
|
||||
});
|
||||
|
||||
let response = AuthenticateResponse::from_jsonrpc(&json).unwrap();
|
||||
|
||||
assert!(!response.success);
|
||||
assert_eq!(response.error, Some("Invalid API key".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_auth_required() {
|
||||
assert!(!is_auth_required(&[]));
|
||||
assert!(is_auth_required(&["api_key".to_string()]));
|
||||
assert!(is_auth_required(&["api_key".to_string(), "oauth".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_authenticate_response_serialization() {
|
||||
let response = AuthenticateResponse {
|
||||
success: true,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
let deserialized: AuthenticateResponse = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(response, deserialized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Cancellation and disconnect handling for ACP.
|
||||
//!
|
||||
//! This module implements:
|
||||
//! - Cancellation cleanup (tool calls, permission requests, state reset)
|
||||
//! - Disconnect detection and handling
|
||||
//! - Timeout handling for cancellation
|
||||
|
||||
use crate::acp::connector_state::{ConnectionState, SessionState};
|
||||
use crate::acp::protocol::streaming::{ToolCallInfo, ToolCallStatus};
|
||||
use crate::acp::transport::{Transport, TransportError, TransportState};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Errors that can occur during cancellation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CancellationError {
|
||||
#[error("Transport error during cancellation: {0}")]
|
||||
TransportError(#[from] TransportError),
|
||||
|
||||
#[error("Cancellation timed out after {0:?}")]
|
||||
Timeout(Duration),
|
||||
|
||||
#[error("Session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
}
|
||||
|
||||
/// Handle cancellation of a prompt turn.
|
||||
///
|
||||
/// This function performs comprehensive cleanup when a user cancels a prompt:
|
||||
/// 1. Marks all pending tool calls as cancelled
|
||||
/// 2. Sends session/cancel notification to agent
|
||||
/// 3. Waits for agent to respond with stopReason: cancelled (with timeout)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_state` - The session state to clean up
|
||||
/// * `transport` - The transport to send cancellation notification
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Ok(()) if cancellation completed successfully, or an error if it failed.
|
||||
pub async fn handle_cancellation(
|
||||
session_state: &mut SessionState,
|
||||
transport: &mut dyn Transport,
|
||||
) -> Result<(), CancellationError> {
|
||||
// Mark session as cancelling
|
||||
session_state.start_cancellation();
|
||||
|
||||
// Send session/cancel notification
|
||||
let notification = crate::acp::protocol::session::SessionCancelNotification::new(
|
||||
session_state.session_id.clone(),
|
||||
);
|
||||
|
||||
let jsonrpc_notification = crate::acp::transport::JsonRpcNotification {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: "session/cancel".to_string(),
|
||||
params: Some(
|
||||
serde_json::to_value(¬ification)
|
||||
.expect("Failed to serialize SessionCancelNotification"),
|
||||
),
|
||||
};
|
||||
|
||||
transport.send_notification(jsonrpc_notification).await?;
|
||||
|
||||
// Note: Actual waiting for stopReason: cancelled is done by the prompt
|
||||
// response handler. This function just initiates the cancellation.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark all pending tool calls as cancelled.
|
||||
///
|
||||
/// This is called immediately when cancellation is initiated, before
|
||||
/// waiting for the agent to respond.
|
||||
pub fn cancel_pending_tool_calls(tool_calls: &mut [ToolCallInfo]) {
|
||||
for tool_call in tool_calls.iter_mut() {
|
||||
if !tool_call.is_terminal() {
|
||||
tool_call.status = ToolCallStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle transport disconnect.
|
||||
///
|
||||
/// This function is called when the transport connection is lost. It:
|
||||
/// 1. Marks all pending operations as failed
|
||||
/// 2. Updates connection state to Disconnected
|
||||
/// 3. Cleans up resources
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_state` - The session state to clean up
|
||||
/// * `connection_state` - The connection state to update
|
||||
/// * `tool_calls` - Active tool calls to mark as failed
|
||||
pub fn handle_disconnect(
|
||||
session_state: &mut SessionState,
|
||||
connection_state: &mut ConnectionState,
|
||||
tool_calls: &mut [ToolCallInfo],
|
||||
) {
|
||||
// Mark all pending tool calls as failed
|
||||
for tool_call in tool_calls.iter_mut() {
|
||||
if !tool_call.is_terminal() {
|
||||
tool_call.status = ToolCallStatus::Failed;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session flags
|
||||
session_state.complete_prompt();
|
||||
session_state.cancelling = false;
|
||||
session_state.loading = false;
|
||||
|
||||
// Update connection state
|
||||
*connection_state = ConnectionState::Disconnected;
|
||||
}
|
||||
|
||||
/// Check if a transport is connected.
|
||||
pub fn is_transport_connected(transport: &dyn Transport) -> bool {
|
||||
transport.is_connected() && transport.state() == TransportState::Connected
|
||||
}
|
||||
|
||||
/// Detect if a disconnect has occurred.
|
||||
///
|
||||
/// This checks the transport state and returns true if the connection
|
||||
/// has been lost.
|
||||
pub fn detect_disconnect(transport: &dyn Transport) -> bool {
|
||||
!is_transport_connected(transport)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::protocol::streaming::ToolKind;
|
||||
|
||||
#[test]
|
||||
fn test_cancel_pending_tool_calls() {
|
||||
let mut tool_calls = vec![
|
||||
ToolCallInfo::new(
|
||||
"tool-1".to_string(),
|
||||
"Test 1".to_string(),
|
||||
ToolKind::Read,
|
||||
ToolCallStatus::Pending,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
ToolCallInfo::new(
|
||||
"tool-2".to_string(),
|
||||
"Test 2".to_string(),
|
||||
ToolKind::Edit,
|
||||
ToolCallStatus::InProgress,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
ToolCallInfo::new(
|
||||
"tool-3".to_string(),
|
||||
"Test 3".to_string(),
|
||||
ToolKind::Execute,
|
||||
ToolCallStatus::Completed,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
cancel_pending_tool_calls(&mut tool_calls);
|
||||
|
||||
// First two should be cancelled (not terminal)
|
||||
assert_eq!(tool_calls[0].status, ToolCallStatus::Cancelled);
|
||||
assert_eq!(tool_calls[1].status, ToolCallStatus::Cancelled);
|
||||
|
||||
// Last one should remain completed (already terminal)
|
||||
assert_eq!(tool_calls[2].status, ToolCallStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_disconnect() {
|
||||
let mut session_state =
|
||||
SessionState::new("session-123".to_string(), "/path".to_string(), vec![]);
|
||||
session_state.start_prompt();
|
||||
|
||||
let mut connection_state = ConnectionState::Ready;
|
||||
|
||||
let mut tool_calls = vec![
|
||||
ToolCallInfo::new(
|
||||
"tool-1".to_string(),
|
||||
"Test".to_string(),
|
||||
ToolKind::Read,
|
||||
ToolCallStatus::InProgress,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
ToolCallInfo::new(
|
||||
"tool-2".to_string(),
|
||||
"Test 2".to_string(),
|
||||
ToolKind::Edit,
|
||||
ToolCallStatus::Completed,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
handle_disconnect(&mut session_state, &mut connection_state, &mut tool_calls);
|
||||
|
||||
// In-progress tool call should be marked failed
|
||||
assert_eq!(tool_calls[0].status, ToolCallStatus::Failed);
|
||||
|
||||
// Completed tool call should remain completed
|
||||
assert_eq!(tool_calls[1].status, ToolCallStatus::Completed);
|
||||
|
||||
// Session state should be cleared
|
||||
assert!(!session_state.prompt_in_progress);
|
||||
assert!(!session_state.cancelling);
|
||||
assert!(!session_state.loading);
|
||||
|
||||
// Connection state should be disconnected
|
||||
assert_eq!(connection_state, ConnectionState::Disconnected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_cancellation_state() {
|
||||
let mut session_state =
|
||||
SessionState::new("session-123".to_string(), "/path".to_string(), vec![]);
|
||||
|
||||
// Start a prompt
|
||||
session_state.start_prompt();
|
||||
assert!(session_state.prompt_in_progress);
|
||||
assert!(!session_state.is_ready());
|
||||
|
||||
// Start cancellation
|
||||
session_state.start_cancellation();
|
||||
assert!(session_state.cancelling);
|
||||
assert!(!session_state.is_ready());
|
||||
|
||||
// Complete the prompt (after cancel)
|
||||
session_state.complete_prompt();
|
||||
assert!(!session_state.prompt_in_progress);
|
||||
assert!(!session_state.cancelling);
|
||||
assert!(session_state.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Capability validation for ACP.
|
||||
//!
|
||||
//! This module implements validation logic to ensure that agent requests only
|
||||
//! use capabilities that the client advertised as supported. Requests for
|
||||
//! unsupported capabilities are rejected with clear error messages.
|
||||
|
||||
use crate::acp::connector_state::ClientCapabilities;
|
||||
use crate::acp::transport::JsonRpcError;
|
||||
|
||||
/// JSON-RPC error code for method not found (unsupported capability).
|
||||
pub const ERROR_METHOD_NOT_FOUND: i32 = -32601;
|
||||
|
||||
/// Result of capability validation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CapabilityValidation {
|
||||
/// The capability is supported and the method can proceed.
|
||||
Supported,
|
||||
/// The capability is not supported.
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// Validate that a method is supported by the client's advertised capabilities.
|
||||
///
|
||||
/// This checks the method name against the client capabilities and returns
|
||||
/// whether the method should be allowed to proceed.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `method` - The JSON-RPC method name to validate
|
||||
/// * `capabilities` - The client capabilities advertised during initialization
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `CapabilityValidation::Supported` if the method is allowed, or
|
||||
/// `CapabilityValidation::Unsupported` with an error message if not.
|
||||
pub fn validate_capability(
|
||||
method: &str,
|
||||
capabilities: &ClientCapabilities,
|
||||
) -> CapabilityValidation {
|
||||
match method {
|
||||
// Filesystem capabilities
|
||||
"fs/read_text_file" => {
|
||||
if capabilities
|
||||
.fs
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.read_text_file)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
CapabilityValidation::Supported
|
||||
} else {
|
||||
CapabilityValidation::Unsupported(
|
||||
"fs/read_text_file capability not advertised".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
"fs/write_text_file" => {
|
||||
if capabilities
|
||||
.fs
|
||||
.as_ref()
|
||||
.and_then(|fs| fs.write_text_file)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
CapabilityValidation::Supported
|
||||
} else {
|
||||
CapabilityValidation::Unsupported(
|
||||
"fs/write_text_file capability not advertised".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal capabilities
|
||||
method if method.starts_with("terminal/") => {
|
||||
if capabilities.terminal.unwrap_or(false) {
|
||||
CapabilityValidation::Supported
|
||||
} else {
|
||||
CapabilityValidation::Unsupported(
|
||||
"terminal capability not advertised".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// All other methods are assumed to be core protocol methods that don't
|
||||
// require capability checks (initialize, authenticate, session/*, etc.)
|
||||
_ => CapabilityValidation::Supported,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a JSON-RPC error for an unsupported capability.
|
||||
///
|
||||
/// This generates a properly formatted JSON-RPC error response that can be
|
||||
/// sent back to the agent when it requests an unsupported method.
|
||||
pub fn capability_not_supported_error(method: &str, reason: &str) -> JsonRpcError {
|
||||
JsonRpcError {
|
||||
code: ERROR_METHOD_NOT_FOUND,
|
||||
message: format!("Method not found: {} ({})", method, reason),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a method requires capability validation.
|
||||
///
|
||||
/// Core protocol methods (initialize, authenticate, session operations) do not
|
||||
/// require capability checks. Tool methods (fs/*, terminal/*) do require checks.
|
||||
pub fn requires_capability_check(method: &str) -> bool {
|
||||
method.starts_with("fs/") || method.starts_with("terminal/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::connector_state::FsCapabilities;
|
||||
|
||||
#[test]
|
||||
fn test_validate_read_capability_supported() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = validate_capability("fs/read_text_file", &caps);
|
||||
assert_eq!(result, CapabilityValidation::Supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_read_capability_unsupported() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(false),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = validate_capability("fs/read_text_file", &caps);
|
||||
assert!(matches!(result, CapabilityValidation::Unsupported(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_write_capability_supported() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(true),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = validate_capability("fs/write_text_file", &caps);
|
||||
assert_eq!(result, CapabilityValidation::Supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_write_capability_unsupported() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = validate_capability("fs/write_text_file", &caps);
|
||||
assert!(matches!(result, CapabilityValidation::Unsupported(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_terminal_capability_supported() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: None,
|
||||
terminal: Some(true),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = validate_capability("terminal/execute", &caps);
|
||||
assert_eq!(result, CapabilityValidation::Supported);
|
||||
|
||||
let result = validate_capability("terminal/read", &caps);
|
||||
assert_eq!(result, CapabilityValidation::Supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_terminal_capability_unsupported() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: None,
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = validate_capability("terminal/execute", &caps);
|
||||
assert!(matches!(result, CapabilityValidation::Unsupported(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_core_methods_always_supported() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: None,
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Core protocol methods should always be supported
|
||||
assert_eq!(
|
||||
validate_capability("initialize", &caps),
|
||||
CapabilityValidation::Supported
|
||||
);
|
||||
assert_eq!(
|
||||
validate_capability("authenticate", &caps),
|
||||
CapabilityValidation::Supported
|
||||
);
|
||||
assert_eq!(
|
||||
validate_capability("session/new", &caps),
|
||||
CapabilityValidation::Supported
|
||||
);
|
||||
assert_eq!(
|
||||
validate_capability("session/prompt", &caps),
|
||||
CapabilityValidation::Supported
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_capability_missing_fs() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: None,
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = validate_capability("fs/read_text_file", &caps);
|
||||
assert!(matches!(result, CapabilityValidation::Unsupported(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capability_not_supported_error() {
|
||||
let error = capability_not_supported_error("fs/write_text_file", "write capability not enabled");
|
||||
|
||||
assert_eq!(error.code, ERROR_METHOD_NOT_FOUND);
|
||||
assert!(error.message.contains("fs/write_text_file"));
|
||||
assert!(error.message.contains("write capability not enabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requires_capability_check() {
|
||||
// Tool methods require checks
|
||||
assert!(requires_capability_check("fs/read_text_file"));
|
||||
assert!(requires_capability_check("fs/write_text_file"));
|
||||
assert!(requires_capability_check("terminal/execute"));
|
||||
|
||||
// Core methods don't require checks
|
||||
assert!(!requires_capability_check("initialize"));
|
||||
assert!(!requires_capability_check("authenticate"));
|
||||
assert!(!requires_capability_check("session/new"));
|
||||
assert!(!requires_capability_check("session/prompt"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Error classification and handling for ACP.
|
||||
//!
|
||||
//! This module implements:
|
||||
//! - Error classification (transient vs terminal vs user)
|
||||
//! - Retry logic with exponential backoff
|
||||
//! - Error surfacing to UI
|
||||
//! - JSON-RPC error code mapping
|
||||
|
||||
use crate::acp::transport::{JsonRpcError, TransportError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Classification of an error.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorClass {
|
||||
/// Transient error that may succeed on retry (network issues, timeouts).
|
||||
Transient,
|
||||
/// Terminal error that requires user intervention (auth failure, unsupported feature).
|
||||
Terminal,
|
||||
/// User error (invalid input, permission denied).
|
||||
User,
|
||||
}
|
||||
|
||||
/// Classified error with context.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClassifiedError {
|
||||
/// Error classification.
|
||||
pub class: ErrorClass,
|
||||
/// JSON-RPC error code (if applicable).
|
||||
pub code: Option<i32>,
|
||||
/// User-facing message.
|
||||
pub message: String,
|
||||
/// Technical details for logs.
|
||||
pub details: Option<String>,
|
||||
/// Suggested retry delay (for transient errors).
|
||||
pub retry_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ClassifiedError {
|
||||
/// Create a new classified error.
|
||||
pub fn new(
|
||||
class: ErrorClass,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
class,
|
||||
code: None,
|
||||
message: message.into(),
|
||||
details: None,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the error code.
|
||||
pub fn with_code(mut self, code: i32) -> Self {
|
||||
self.code = Some(code);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set technical details.
|
||||
pub fn with_details(mut self, details: impl Into<String>) -> Self {
|
||||
self.details = Some(details.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set retry delay.
|
||||
pub fn with_retry_after(mut self, duration: Duration) -> Self {
|
||||
self.retry_after = Some(duration);
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if this error is retryable.
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
self.class == ErrorClass::Transient
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a JSON-RPC error.
|
||||
pub fn classify_jsonrpc_error(error: &JsonRpcError) -> ClassifiedError {
|
||||
match error.code {
|
||||
// JSON-RPC standard errors
|
||||
-32700 => ClassifiedError::new(
|
||||
ErrorClass::Terminal,
|
||||
"Invalid JSON received",
|
||||
)
|
||||
.with_code(-32700)
|
||||
.with_details(error.message.clone()),
|
||||
|
||||
-32600 => ClassifiedError::new(
|
||||
ErrorClass::Terminal,
|
||||
"Invalid request format",
|
||||
)
|
||||
.with_code(-32600)
|
||||
.with_details(error.message.clone()),
|
||||
|
||||
-32601 => ClassifiedError::new(
|
||||
ErrorClass::Terminal,
|
||||
"Method not supported",
|
||||
)
|
||||
.with_code(-32601)
|
||||
.with_details(error.message.clone()),
|
||||
|
||||
-32602 => ClassifiedError::new(
|
||||
ErrorClass::User,
|
||||
"Invalid parameters",
|
||||
)
|
||||
.with_code(-32602)
|
||||
.with_details(error.message.clone()),
|
||||
|
||||
-32603 => ClassifiedError::new(
|
||||
ErrorClass::Transient,
|
||||
"Internal error, please try again",
|
||||
)
|
||||
.with_code(-32603)
|
||||
.with_details(error.message.clone()),
|
||||
|
||||
// Custom error codes (examples)
|
||||
-40001 => ClassifiedError::new(
|
||||
ErrorClass::Terminal,
|
||||
"Authentication required",
|
||||
)
|
||||
.with_code(-40001)
|
||||
.with_details(error.message.clone()),
|
||||
|
||||
-40003 => {
|
||||
// Rate limit error
|
||||
let retry_after = parse_retry_after(&error.data);
|
||||
ClassifiedError::new(
|
||||
ErrorClass::Transient,
|
||||
"Rate limit exceeded, please wait",
|
||||
)
|
||||
.with_code(-40003)
|
||||
.with_details(error.message.clone())
|
||||
.with_retry_after(retry_after)
|
||||
}
|
||||
|
||||
-40401 => ClassifiedError::new(
|
||||
ErrorClass::User,
|
||||
error.message.clone(),
|
||||
)
|
||||
.with_code(-40401),
|
||||
|
||||
// Default: treat unknown errors as transient
|
||||
_ => ClassifiedError::new(
|
||||
ErrorClass::Transient,
|
||||
error.message.clone(),
|
||||
)
|
||||
.with_code(error.code)
|
||||
.with_details(format!("Unknown error code: {}", error.code)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a transport error.
|
||||
pub fn classify_transport_error(error: &TransportError) -> ClassifiedError {
|
||||
match error {
|
||||
TransportError::ConnectionError(msg) => {
|
||||
ClassifiedError::new(ErrorClass::Transient, format!("Connection error: {}", msg))
|
||||
.with_retry_after(Duration::from_secs(2))
|
||||
}
|
||||
TransportError::IoError(io_err) => {
|
||||
ClassifiedError::new(ErrorClass::Transient, format!("I/O error: {}", io_err))
|
||||
.with_retry_after(Duration::from_secs(2))
|
||||
}
|
||||
TransportError::SerializationError(serde_err) => {
|
||||
ClassifiedError::new(ErrorClass::Terminal, format!("Serialization error: {}", serde_err))
|
||||
}
|
||||
TransportError::JsonRpcError(jsonrpc_err) => classify_jsonrpc_error(jsonrpc_err),
|
||||
TransportError::Closed => {
|
||||
ClassifiedError::new(ErrorClass::Terminal, "Transport closed")
|
||||
}
|
||||
TransportError::Timeout => {
|
||||
ClassifiedError::new(ErrorClass::Transient, "Request timed out")
|
||||
.with_retry_after(Duration::from_secs(5))
|
||||
}
|
||||
TransportError::ProcessExited => {
|
||||
ClassifiedError::new(ErrorClass::Terminal, "Process exited unexpectedly")
|
||||
}
|
||||
TransportError::HttpError(msg) => {
|
||||
ClassifiedError::new(ErrorClass::Transient, format!("HTTP error: {}", msg))
|
||||
.with_retry_after(Duration::from_secs(2))
|
||||
}
|
||||
TransportError::SseError(msg) => {
|
||||
ClassifiedError::new(ErrorClass::Transient, format!("SSE error: {}", msg))
|
||||
.with_retry_after(Duration::from_secs(2))
|
||||
}
|
||||
TransportError::Other(msg) => {
|
||||
ClassifiedError::new(ErrorClass::Transient, msg.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse retry-after duration from error data.
|
||||
///
|
||||
/// This looks for a "retry_after" field in the error data that specifies
|
||||
/// how long to wait before retrying.
|
||||
fn parse_retry_after(data: &Option<serde_json::Value>) -> Duration {
|
||||
if let Some(serde_json::Value::Object(map)) = data {
|
||||
if let Some(serde_json::Value::Number(secs)) = map.get("retry_after") {
|
||||
if let Some(secs) = secs.as_u64() {
|
||||
return Duration::from_secs(secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default retry delay
|
||||
Duration::from_secs(10)
|
||||
}
|
||||
|
||||
/// Calculate exponential backoff delay.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `attempt` - The attempt number (0-indexed)
|
||||
/// * `base_delay` - Base delay for first retry
|
||||
/// * `max_delay` - Maximum delay cap
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The delay to wait before the next attempt.
|
||||
pub fn exponential_backoff(attempt: u32, base_delay: Duration, max_delay: Duration) -> Duration {
|
||||
let delay_secs = base_delay.as_secs() * 2u64.pow(attempt);
|
||||
let delay = Duration::from_secs(delay_secs);
|
||||
delay.min(max_delay)
|
||||
}
|
||||
|
||||
/// Severity level for UI error surfacing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorSeverity {
|
||||
/// Informational message.
|
||||
Info,
|
||||
/// Warning that user should be aware of.
|
||||
Warning,
|
||||
/// Error requiring user attention.
|
||||
Error,
|
||||
/// Fatal error requiring restart or reconnect.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
/// Suggested actions for errors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorAction {
|
||||
/// Retry the operation.
|
||||
Retry,
|
||||
/// Reconnect to the agent.
|
||||
Reconnect,
|
||||
/// Check configuration.
|
||||
CheckConfig,
|
||||
/// Contact support.
|
||||
ContactSupport,
|
||||
/// Dismiss the error.
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
/// Map error classification to UI severity.
|
||||
pub fn error_severity(classified: &ClassifiedError) -> ErrorSeverity {
|
||||
match classified.class {
|
||||
ErrorClass::Transient => ErrorSeverity::Warning,
|
||||
ErrorClass::Terminal => ErrorSeverity::Error,
|
||||
ErrorClass::User => ErrorSeverity::Info,
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest actions for an error.
|
||||
pub fn error_actions(classified: &ClassifiedError) -> Vec<ErrorAction> {
|
||||
match classified.class {
|
||||
ErrorClass::Transient => vec![ErrorAction::Retry, ErrorAction::Dismiss],
|
||||
ErrorClass::Terminal => {
|
||||
if classified.code == Some(-40001) {
|
||||
// Auth error
|
||||
vec![ErrorAction::CheckConfig, ErrorAction::ContactSupport]
|
||||
} else {
|
||||
vec![ErrorAction::CheckConfig, ErrorAction::Dismiss]
|
||||
}
|
||||
}
|
||||
ErrorClass::User => vec![ErrorAction::Dismiss],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_classify_parse_error() {
|
||||
let error = JsonRpcError {
|
||||
code: -32700,
|
||||
message: "Parse error".to_string(),
|
||||
data: None,
|
||||
};
|
||||
|
||||
let classified = classify_jsonrpc_error(&error);
|
||||
assert_eq!(classified.class, ErrorClass::Terminal);
|
||||
assert_eq!(classified.code, Some(-32700));
|
||||
assert!(!classified.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_invalid_params() {
|
||||
let error = JsonRpcError {
|
||||
code: -32602,
|
||||
message: "Invalid params".to_string(),
|
||||
data: None,
|
||||
};
|
||||
|
||||
let classified = classify_jsonrpc_error(&error);
|
||||
assert_eq!(classified.class, ErrorClass::User);
|
||||
assert!(!classified.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_internal_error() {
|
||||
let error = JsonRpcError {
|
||||
code: -32603,
|
||||
message: "Internal error".to_string(),
|
||||
data: None,
|
||||
};
|
||||
|
||||
let classified = classify_jsonrpc_error(&error);
|
||||
assert_eq!(classified.class, ErrorClass::Transient);
|
||||
assert!(classified.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_rate_limit() {
|
||||
let error = JsonRpcError {
|
||||
code: -40003,
|
||||
message: "Rate limited".to_string(),
|
||||
data: Some(serde_json::json!({"retry_after": 30})),
|
||||
};
|
||||
|
||||
let classified = classify_jsonrpc_error(&error);
|
||||
assert_eq!(classified.class, ErrorClass::Transient);
|
||||
assert_eq!(classified.retry_after, Some(Duration::from_secs(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_transport_timeout() {
|
||||
let error = TransportError::Timeout;
|
||||
let classified = classify_transport_error(&error);
|
||||
assert_eq!(classified.class, ErrorClass::Transient);
|
||||
assert!(classified.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_transport_closed() {
|
||||
let error = TransportError::Closed;
|
||||
let classified = classify_transport_error(&error);
|
||||
assert_eq!(classified.class, ErrorClass::Terminal);
|
||||
assert!(!classified.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
let base = Duration::from_secs(1);
|
||||
let max = Duration::from_secs(60);
|
||||
|
||||
assert_eq!(exponential_backoff(0, base, max), Duration::from_secs(1));
|
||||
assert_eq!(exponential_backoff(1, base, max), Duration::from_secs(2));
|
||||
assert_eq!(exponential_backoff(2, base, max), Duration::from_secs(4));
|
||||
assert_eq!(exponential_backoff(3, base, max), Duration::from_secs(8));
|
||||
assert_eq!(exponential_backoff(10, base, max), Duration::from_secs(60)); // Capped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_severity_mapping() {
|
||||
let transient = ClassifiedError::new(ErrorClass::Transient, "Test");
|
||||
assert_eq!(error_severity(&transient), ErrorSeverity::Warning);
|
||||
|
||||
let terminal = ClassifiedError::new(ErrorClass::Terminal, "Test");
|
||||
assert_eq!(error_severity(&terminal), ErrorSeverity::Error);
|
||||
|
||||
let user = ClassifiedError::new(ErrorClass::User, "Test");
|
||||
assert_eq!(error_severity(&user), ErrorSeverity::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_actions() {
|
||||
let transient = ClassifiedError::new(ErrorClass::Transient, "Test");
|
||||
let actions = error_actions(&transient);
|
||||
assert!(actions.contains(&ErrorAction::Retry));
|
||||
|
||||
let terminal = ClassifiedError::new(ErrorClass::Terminal, "Test");
|
||||
let actions = error_actions(&terminal);
|
||||
assert!(actions.contains(&ErrorAction::CheckConfig));
|
||||
|
||||
let user = ClassifiedError::new(ErrorClass::User, "Test");
|
||||
let actions = error_actions(&user);
|
||||
assert!(actions.contains(&ErrorAction::Dismiss));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_retry_after() {
|
||||
let data = Some(serde_json::json!({"retry_after": 15}));
|
||||
assert_eq!(parse_retry_after(&data), Duration::from_secs(15));
|
||||
|
||||
let no_data = None;
|
||||
assert_eq!(parse_retry_after(&no_data), Duration::from_secs(10)); // Default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Initialize method implementation for ACP.
|
||||
//!
|
||||
//! This module implements the `initialize` JSON-RPC method, which is the first
|
||||
//! required interaction in the ACP protocol. Both client and agent must agree
|
||||
//! on protocol version and exchange capability information.
|
||||
|
||||
use crate::acp::connector_state::{
|
||||
AgentCapabilities, ClientCapabilities, ImplementationInfo,
|
||||
};
|
||||
use crate::acp::transport::{JsonRpcRequest, JsonRpcResult, Transport, TransportError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Current ACP protocol version supported by this implementation.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// Initialize request sent to the agent.
|
||||
///
|
||||
/// This is the first message sent after establishing a transport connection.
|
||||
/// The client advertises its capabilities and requests the agent's capabilities.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct InitializeRequest {
|
||||
/// Protocol version supported by the client.
|
||||
pub protocol_version: u32,
|
||||
|
||||
/// Capabilities that the client supports.
|
||||
pub client_capabilities: ClientCapabilities,
|
||||
|
||||
/// Information about the client implementation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_info: Option<ImplementationInfo>,
|
||||
}
|
||||
|
||||
impl InitializeRequest {
|
||||
/// Create a new initialize request with the current protocol version and capabilities.
|
||||
pub fn new(
|
||||
client_capabilities: ClientCapabilities,
|
||||
client_info: Option<ImplementationInfo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_capabilities,
|
||||
client_info,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC request.
|
||||
pub fn to_jsonrpc(&self, id: u64) -> JsonRpcRequest {
|
||||
JsonRpcRequest::new(
|
||||
id,
|
||||
"initialize",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize InitializeRequest")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize response from the agent.
|
||||
///
|
||||
/// The agent responds with its supported protocol version, capabilities,
|
||||
/// and optional authentication requirements.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct InitializeResponse {
|
||||
/// Protocol version supported by the agent.
|
||||
pub protocol_version: u32,
|
||||
|
||||
/// Capabilities that the agent supports.
|
||||
pub agent_capabilities: AgentCapabilities,
|
||||
|
||||
/// Information about the agent implementation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_info: Option<ImplementationInfo>,
|
||||
|
||||
/// Authentication methods supported by the agent (empty if no auth required).
|
||||
#[serde(default)]
|
||||
pub auth_methods: Vec<String>,
|
||||
}
|
||||
|
||||
impl InitializeResponse {
|
||||
/// Parse from JSON-RPC result.
|
||||
pub fn from_jsonrpc(result: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_value(result.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of initialization attempt.
|
||||
#[derive(Debug)]
|
||||
pub enum InitializeResult {
|
||||
/// Initialization succeeded with negotiated version and agent capabilities.
|
||||
Success {
|
||||
protocol_version: u32,
|
||||
agent_capabilities: AgentCapabilities,
|
||||
agent_info: Option<ImplementationInfo>,
|
||||
auth_methods: Vec<String>,
|
||||
},
|
||||
/// Version mismatch - client and agent don't support compatible versions.
|
||||
VersionMismatch {
|
||||
client_version: u32,
|
||||
agent_version: u32,
|
||||
},
|
||||
/// Other error during initialization.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Perform initialization handshake with the agent.
|
||||
///
|
||||
/// This sends an initialize request and processes the response, performing
|
||||
/// version negotiation and capability exchange.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `client_capabilities` - Capabilities that the client supports
|
||||
/// * `client_info` - Optional client implementation information
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Result of the initialization attempt, including negotiated version and
|
||||
/// agent capabilities on success.
|
||||
pub async fn initialize(
|
||||
transport: &mut dyn Transport,
|
||||
client_capabilities: ClientCapabilities,
|
||||
client_info: Option<ImplementationInfo>,
|
||||
) -> Result<InitializeResult, TransportError> {
|
||||
// Create initialize request
|
||||
let request = InitializeRequest::new(client_capabilities, client_info);
|
||||
let jsonrpc_request = request.to_jsonrpc(1);
|
||||
|
||||
// Send request and await response
|
||||
let result = transport.send_request(jsonrpc_request).await?;
|
||||
|
||||
// Handle response
|
||||
match result {
|
||||
JsonRpcResult::Success(response) => {
|
||||
// Parse initialize response
|
||||
let init_response = InitializeResponse::from_jsonrpc(&response.result)
|
||||
.map_err(|e| TransportError::SerializationError(e))?;
|
||||
|
||||
// Check protocol version compatibility
|
||||
if init_response.protocol_version != PROTOCOL_VERSION {
|
||||
return Ok(InitializeResult::VersionMismatch {
|
||||
client_version: PROTOCOL_VERSION,
|
||||
agent_version: init_response.protocol_version,
|
||||
});
|
||||
}
|
||||
|
||||
// Version matches, initialization successful
|
||||
Ok(InitializeResult::Success {
|
||||
protocol_version: init_response.protocol_version,
|
||||
agent_capabilities: init_response.agent_capabilities,
|
||||
agent_info: init_response.agent_info,
|
||||
auth_methods: init_response.auth_methods,
|
||||
})
|
||||
}
|
||||
JsonRpcResult::Error(error_response) => {
|
||||
// Initialization failed with JSON-RPC error
|
||||
Ok(InitializeResult::Error(error_response.error.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that client and agent protocol versions are compatible.
|
||||
///
|
||||
/// Currently, we require exact version match. In the future, this could
|
||||
/// be enhanced to support backward compatibility.
|
||||
pub fn is_version_compatible(client_version: u32, agent_version: u32) -> bool {
|
||||
client_version == agent_version
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::connector_state::FsCapabilities;
|
||||
|
||||
#[test]
|
||||
fn test_initialize_request_creation() {
|
||||
let caps = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let request = InitializeRequest::new(caps.clone(), None);
|
||||
|
||||
assert_eq!(request.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(request.client_capabilities, caps);
|
||||
assert!(request.client_info.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_request_with_client_info() {
|
||||
let caps = ClientCapabilities::default_safe();
|
||||
let info = ImplementationInfo {
|
||||
name: "dirigent".to_string(),
|
||||
title: Some("Dirigent ACP Client".to_string()),
|
||||
version: Some("0.1.0".to_string()),
|
||||
};
|
||||
|
||||
let request = InitializeRequest::new(caps, Some(info.clone()));
|
||||
|
||||
assert!(request.client_info.is_some());
|
||||
assert_eq!(request.client_info.unwrap(), info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_request_serialization() {
|
||||
let caps = ClientCapabilities::default_safe();
|
||||
let request = InitializeRequest::new(caps, None);
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
let deserialized: InitializeRequest = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(request, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_request_to_jsonrpc() {
|
||||
let caps = ClientCapabilities::default_safe();
|
||||
let request = InitializeRequest::new(caps, None);
|
||||
|
||||
let jsonrpc = request.to_jsonrpc(42);
|
||||
|
||||
assert_eq!(jsonrpc.method, "initialize");
|
||||
assert_eq!(jsonrpc.id, serde_json::Value::Number(42.into()));
|
||||
assert!(jsonrpc.params.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_response_parsing() {
|
||||
let json = serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"agent_capabilities": {
|
||||
"load_session": true,
|
||||
"prompt_capabilities": {
|
||||
"image": true,
|
||||
"audio": false,
|
||||
"embedded_context": true
|
||||
}
|
||||
},
|
||||
"agent_info": {
|
||||
"name": "test-agent",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"auth_methods": []
|
||||
});
|
||||
|
||||
let response = InitializeResponse::from_jsonrpc(&json).unwrap();
|
||||
|
||||
assert_eq!(response.protocol_version, 1);
|
||||
assert!(response.agent_capabilities.load_session.unwrap());
|
||||
assert_eq!(response.auth_methods.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_response_with_auth() {
|
||||
let json = serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"agent_capabilities": {},
|
||||
"auth_methods": ["api_key", "oauth"]
|
||||
});
|
||||
|
||||
let response = InitializeResponse::from_jsonrpc(&json).unwrap();
|
||||
|
||||
assert_eq!(response.auth_methods.len(), 2);
|
||||
assert!(response.auth_methods.contains(&"api_key".to_string()));
|
||||
assert!(response.auth_methods.contains(&"oauth".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_compatibility() {
|
||||
assert!(is_version_compatible(1, 1));
|
||||
assert!(!is_version_compatible(1, 2));
|
||||
assert!(!is_version_compatible(2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_response_missing_optional_fields() {
|
||||
let json = serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"agent_capabilities": {}
|
||||
});
|
||||
|
||||
let response = InitializeResponse::from_jsonrpc(&json).unwrap();
|
||||
|
||||
assert_eq!(response.protocol_version, 1);
|
||||
assert!(response.agent_info.is_none());
|
||||
assert_eq!(response.auth_methods.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! ACP protocol implementation.
|
||||
//!
|
||||
//! This module implements the Agent-Client Protocol (ACP) message handling,
|
||||
//! including initialization, authentication, session lifecycle, prompt turns,
|
||||
//! streaming updates, cancellation, and error handling.
|
||||
|
||||
pub mod initialize;
|
||||
pub mod authenticate;
|
||||
pub mod capabilities;
|
||||
pub mod session;
|
||||
pub mod prompt;
|
||||
pub mod streaming;
|
||||
pub mod stop_reason;
|
||||
pub mod cancellation;
|
||||
pub mod error;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use initialize::{InitializeRequest, InitializeResponse};
|
||||
pub use authenticate::{AuthenticateRequest, AuthenticateResponse};
|
||||
pub use capabilities::validate_capability;
|
||||
pub use session::{
|
||||
SessionCancelNotification, SessionLoadRequest, SessionLoadResponse, SessionNewRequest,
|
||||
SessionNewResponse, SessionSetModeRequest, SessionSetModeResponse, SessionSetModelRequest,
|
||||
SessionSetModelResponse,
|
||||
};
|
||||
pub use prompt::{
|
||||
ContentBlock, SessionPromptRequest, SessionPromptResponse, StopReason, EmbeddedResource,
|
||||
Annotations, PromptError,
|
||||
};
|
||||
pub use streaming::{
|
||||
SessionUpdate, SessionUpdateNotification, ToolKind, ToolCallStatus, ToolCallInfo,
|
||||
ToolCallLocation, ToolCallContent, MessageAccumulator, PlanEntry, Command,
|
||||
};
|
||||
pub use stop_reason::{StopReasonAction, handle_stop_reason, is_continuable, is_error};
|
||||
pub use cancellation::{handle_cancellation, cancel_pending_tool_calls, handle_disconnect};
|
||||
pub use error::{
|
||||
ErrorClass, ClassifiedError, ErrorSeverity, ErrorAction, classify_jsonrpc_error,
|
||||
classify_transport_error, exponential_backoff, error_severity, error_actions,
|
||||
};
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Prompt turn handling for ACP.
|
||||
//!
|
||||
//! This module implements session/prompt requests and responses, including:
|
||||
//! - Content blocks (text, image, audio, resource, resource_link)
|
||||
//! - Prompt request handling with timeout
|
||||
//! - Stop reason interpretation
|
||||
//! - Content validation against agent capabilities
|
||||
|
||||
use crate::acp::connector_state::AgentCapabilities;
|
||||
use crate::acp::transport::{JsonRpcRequest, JsonRpcResult, Transport, TransportError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Request to send a prompt to the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionPromptRequest {
|
||||
/// Session ID to send prompt to.
|
||||
pub session_id: String,
|
||||
/// Content blocks that make up the prompt.
|
||||
pub prompt: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
impl SessionPromptRequest {
|
||||
/// Create a new session/prompt request.
|
||||
pub fn new(session_id: impl Into<String>, prompt: Vec<ContentBlock>) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
prompt,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC request.
|
||||
pub fn to_jsonrpc(&self, id: u64) -> JsonRpcRequest {
|
||||
JsonRpcRequest::new(
|
||||
id,
|
||||
"session/prompt",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize SessionPromptRequest")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate content blocks against agent capabilities.
|
||||
///
|
||||
/// Returns an error if any content block uses a capability the agent
|
||||
/// doesn't support.
|
||||
pub fn validate(&self, agent_capabilities: &AgentCapabilities) -> Result<(), String> {
|
||||
for block in &self.prompt {
|
||||
match block {
|
||||
ContentBlock::Text { .. } => {
|
||||
// Text is always supported
|
||||
}
|
||||
ContentBlock::Image { .. } => {
|
||||
if !agent_capabilities.supports_image() {
|
||||
return Err("Agent does not support image content blocks".to_string());
|
||||
}
|
||||
}
|
||||
ContentBlock::Audio { .. } => {
|
||||
if !agent_capabilities.supports_audio() {
|
||||
return Err("Agent does not support audio content blocks".to_string());
|
||||
}
|
||||
}
|
||||
ContentBlock::Resource { .. } => {
|
||||
if !agent_capabilities.supports_embedded_context() {
|
||||
return Err(
|
||||
"Agent does not support embedded context (resource blocks)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
ContentBlock::ResourceLink { .. } => {
|
||||
// ResourceLink is always supported
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from session/prompt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionPromptResponse {
|
||||
/// Reason the prompt turn stopped.
|
||||
pub stop_reason: StopReason,
|
||||
}
|
||||
|
||||
impl SessionPromptResponse {
|
||||
/// Parse from JSON-RPC result.
|
||||
pub fn from_jsonrpc(result: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_value(result.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reason a prompt turn stopped.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StopReason {
|
||||
/// Normal completion (agent finished its turn).
|
||||
EndTurn,
|
||||
/// Hit token limit for the response.
|
||||
MaxTokens,
|
||||
/// Hit maximum number of LLM requests per turn.
|
||||
MaxTurnRequests,
|
||||
/// Agent refused to continue.
|
||||
Refusal,
|
||||
/// User cancelled the prompt.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Content block in a prompt or message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
/// Plain text content.
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<Annotations>,
|
||||
},
|
||||
/// Image content (base64-encoded).
|
||||
Image {
|
||||
/// Base64-encoded image data.
|
||||
data: String,
|
||||
/// MIME type (e.g., "image/png", "image/jpeg").
|
||||
mime_type: String,
|
||||
/// Optional URI reference.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
uri: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<Annotations>,
|
||||
},
|
||||
/// Audio content (base64-encoded).
|
||||
Audio {
|
||||
/// Base64-encoded audio data.
|
||||
data: String,
|
||||
/// MIME type (e.g., "audio/wav", "audio/mp3").
|
||||
mime_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<Annotations>,
|
||||
},
|
||||
/// Embedded resource (file content or blob).
|
||||
Resource {
|
||||
resource: EmbeddedResource,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<Annotations>,
|
||||
},
|
||||
/// Link to a resource (reference without full content).
|
||||
ResourceLink {
|
||||
/// URI of the resource.
|
||||
uri: String,
|
||||
/// Name/filename of the resource.
|
||||
name: String,
|
||||
/// MIME type (if known).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
/// Human-readable title.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
/// Description of the resource.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
/// Size in bytes (if known).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
annotations: Option<Annotations>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Embedded resource content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum EmbeddedResource {
|
||||
/// Text resource (e.g., file contents).
|
||||
Text {
|
||||
uri: String,
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
/// Binary resource (base64-encoded).
|
||||
Blob {
|
||||
uri: String,
|
||||
/// Base64-encoded binary data.
|
||||
blob: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Annotations for content blocks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Annotations {
|
||||
/// Intended audience for this content (e.g., ["assistant"]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub audience: Option<Vec<String>>,
|
||||
/// Priority hint (higher = more important).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<f64>,
|
||||
}
|
||||
|
||||
/// Error type for prompt operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PromptError {
|
||||
#[error("Prompt timed out after {0:?}")]
|
||||
Timeout(Duration),
|
||||
|
||||
#[error("JSON-RPC error: {0}")]
|
||||
JsonRpcError(crate::acp::transport::JsonRpcError),
|
||||
|
||||
#[error("Transport error: {0}")]
|
||||
TransportError(#[from] TransportError),
|
||||
|
||||
#[error("Prompt was cancelled")]
|
||||
Cancelled,
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
}
|
||||
|
||||
impl From<crate::acp::transport::JsonRpcError> for PromptError {
|
||||
fn from(err: crate::acp::transport::JsonRpcError) -> Self {
|
||||
PromptError::JsonRpcError(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a prompt request to the agent.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `session_id` - ID of the session to prompt
|
||||
/// * `prompt` - Content blocks to send
|
||||
/// * `agent_capabilities` - Agent capabilities (for validation)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The stop reason for the prompt turn.
|
||||
pub async fn session_prompt(
|
||||
transport: &mut dyn Transport,
|
||||
session_id: impl Into<String>,
|
||||
prompt: Vec<ContentBlock>,
|
||||
agent_capabilities: &AgentCapabilities,
|
||||
) -> Result<StopReason, PromptError> {
|
||||
// Create and validate request
|
||||
let request = SessionPromptRequest::new(session_id, prompt);
|
||||
request
|
||||
.validate(agent_capabilities)
|
||||
.map_err(PromptError::ValidationError)?;
|
||||
|
||||
// Convert to JSON-RPC
|
||||
let jsonrpc_request = request.to_jsonrpc(10); // Use ID 10+ for prompts
|
||||
|
||||
// Send request
|
||||
let result = transport.send_request(jsonrpc_request).await?;
|
||||
|
||||
// Handle response
|
||||
match result {
|
||||
JsonRpcResult::Success(response) => {
|
||||
let prompt_response = SessionPromptResponse::from_jsonrpc(&response.result)
|
||||
.map_err(|e| TransportError::SerializationError(e))?;
|
||||
Ok(prompt_response.stop_reason)
|
||||
}
|
||||
JsonRpcResult::Error(error_response) => Err(PromptError::JsonRpcError(error_response.error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a prompt request with a timeout.
|
||||
///
|
||||
/// This function sends a prompt and waits for a response, but will return
|
||||
/// a timeout error if no response is received within the specified duration.
|
||||
pub async fn session_prompt_with_timeout(
|
||||
transport: &mut dyn Transport,
|
||||
session_id: impl Into<String>,
|
||||
prompt: Vec<ContentBlock>,
|
||||
agent_capabilities: &AgentCapabilities,
|
||||
timeout: Duration,
|
||||
) -> Result<StopReason, PromptError> {
|
||||
let session_id = session_id.into();
|
||||
|
||||
match tokio::time::timeout(
|
||||
timeout,
|
||||
session_prompt(transport, session_id, prompt, agent_capabilities),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(PromptError::Timeout(timeout)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::connector_state::PromptCapabilities;
|
||||
|
||||
#[test]
|
||||
fn test_session_prompt_request() {
|
||||
let request = SessionPromptRequest::new(
|
||||
"session-123",
|
||||
vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
annotations: None,
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(request.session_id, "session-123");
|
||||
assert_eq!(request.prompt.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_prompt_request_serialization() {
|
||||
let request = SessionPromptRequest::new(
|
||||
"session-123",
|
||||
vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
annotations: None,
|
||||
}],
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
let deserialized: SessionPromptRequest = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(request, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_block_text() {
|
||||
let block = ContentBlock::Text {
|
||||
text: "Test content".to_string(),
|
||||
annotations: Some(Annotations {
|
||||
audience: Some(vec!["assistant".to_string()]),
|
||||
priority: Some(1.0),
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains("\"type\":\"text\""));
|
||||
assert!(json.contains("Test content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_block_image() {
|
||||
let block = ContentBlock::Image {
|
||||
data: "base64data".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
uri: Some("file://test.png".to_string()),
|
||||
annotations: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains("\"type\":\"image\""));
|
||||
assert!(json.contains("base64data"));
|
||||
assert!(json.contains("image/png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_block_resource_link() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file.txt".to_string(),
|
||||
name: "file.txt".to_string(),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
title: Some("Test File".to_string()),
|
||||
description: Some("A test file".to_string()),
|
||||
size: Some(1024),
|
||||
annotations: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains("\"type\":\"resource_link\""));
|
||||
assert!(json.contains("file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedded_resource_text() {
|
||||
let resource = EmbeddedResource::Text {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
text: "File contents".to_string(),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&resource).unwrap();
|
||||
assert!(json.contains("File contents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_reason_serialization() {
|
||||
let reasons = vec![
|
||||
StopReason::EndTurn,
|
||||
StopReason::MaxTokens,
|
||||
StopReason::MaxTurnRequests,
|
||||
StopReason::Refusal,
|
||||
StopReason::Cancelled,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
let json = serde_json::to_string(&reason).unwrap();
|
||||
let deserialized: StopReason = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(reason, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_text_block() {
|
||||
let request = SessionPromptRequest::new(
|
||||
"session-123",
|
||||
vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
annotations: None,
|
||||
}],
|
||||
);
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: None,
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Text is always supported
|
||||
assert!(request.validate(&caps).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_image_unsupported() {
|
||||
let request = SessionPromptRequest::new(
|
||||
"session-123",
|
||||
vec![ContentBlock::Image {
|
||||
data: "base64".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
uri: None,
|
||||
annotations: None,
|
||||
}],
|
||||
);
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: None,
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Image not supported
|
||||
assert!(request.validate(&caps).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_image_supported() {
|
||||
let request = SessionPromptRequest::new(
|
||||
"session-123",
|
||||
vec![ContentBlock::Image {
|
||||
data: "base64".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
uri: None,
|
||||
annotations: None,
|
||||
}],
|
||||
);
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: Some(PromptCapabilities {
|
||||
image: Some(true),
|
||||
audio: None,
|
||||
embedded_context: None,
|
||||
}),
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Image supported
|
||||
assert!(request.validate(&caps).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_resource_unsupported() {
|
||||
let request = SessionPromptRequest::new(
|
||||
"session-123",
|
||||
vec![ContentBlock::Resource {
|
||||
resource: EmbeddedResource::Text {
|
||||
uri: "file://test".to_string(),
|
||||
text: "content".to_string(),
|
||||
mime_type: None,
|
||||
},
|
||||
annotations: None,
|
||||
}],
|
||||
);
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: None,
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Embedded context not supported
|
||||
assert!(request.validate(&caps).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_resource_supported() {
|
||||
let request = SessionPromptRequest::new(
|
||||
"session-123",
|
||||
vec![ContentBlock::Resource {
|
||||
resource: EmbeddedResource::Text {
|
||||
uri: "file://test".to_string(),
|
||||
text: "content".to_string(),
|
||||
mime_type: None,
|
||||
},
|
||||
annotations: None,
|
||||
}],
|
||||
);
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: Some(PromptCapabilities {
|
||||
image: None,
|
||||
audio: None,
|
||||
embedded_context: Some(true),
|
||||
}),
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Embedded context supported
|
||||
assert!(request.validate(&caps).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
//! Session lifecycle methods for ACP.
|
||||
//!
|
||||
//! This module implements session management methods including:
|
||||
//! - `session/new` - Create new sessions with working directory and MCP servers
|
||||
//! - `session/load` - Load existing sessions with history replay
|
||||
//! - `session/set_mode` - Change session mode (e.g., "code", "chat")
|
||||
//! - `session/set_model` - Change model (optional, unstable in spec)
|
||||
//! - `session/cancel` - Cancel ongoing prompt turns
|
||||
|
||||
use crate::acp::connector_state::{
|
||||
AgentCapabilities, McpServer,
|
||||
};
|
||||
use crate::acp::transport::{JsonRpcRequest, JsonRpcResult, Transport, TransportError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// Request to create a new session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionNewRequest {
|
||||
/// Working directory for the session (absolute path).
|
||||
pub cwd: String,
|
||||
/// MCP servers to use for this session.
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<McpServer>,
|
||||
}
|
||||
|
||||
impl SessionNewRequest {
|
||||
/// Create a new session/new request.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cwd` - Working directory (absolute path)
|
||||
/// * `mcp_servers` - MCP servers to use
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if cwd is not an absolute path.
|
||||
pub fn new(cwd: impl Into<String>, mcp_servers: Vec<McpServer>) -> Self {
|
||||
let cwd = cwd.into();
|
||||
assert!(
|
||||
Path::new(&cwd).is_absolute(),
|
||||
"Working directory must be an absolute path: {}",
|
||||
cwd
|
||||
);
|
||||
Self { cwd, mcp_servers }
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC request.
|
||||
pub fn to_jsonrpc(&self, id: u64) -> JsonRpcRequest {
|
||||
JsonRpcRequest::new(
|
||||
id,
|
||||
"session/new",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize SessionNewRequest")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from session/new.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionNewResponse {
|
||||
/// Unique identifier for the created session.
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
impl SessionNewResponse {
|
||||
/// Parse from JSON-RPC result.
|
||||
pub fn from_jsonrpc(result: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_value(result.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to load an existing session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionLoadRequest {
|
||||
/// Session ID to load.
|
||||
pub session_id: String,
|
||||
/// Working directory for the session (absolute path).
|
||||
pub cwd: String,
|
||||
/// MCP servers to use for this session.
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<McpServer>,
|
||||
}
|
||||
|
||||
impl SessionLoadRequest {
|
||||
/// Create a new session/load request.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - ID of the session to load
|
||||
/// * `cwd` - Working directory (absolute path)
|
||||
/// * `mcp_servers` - MCP servers to use
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if cwd is not an absolute path.
|
||||
pub fn new(
|
||||
session_id: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
mcp_servers: Vec<McpServer>,
|
||||
) -> Self {
|
||||
let cwd = cwd.into();
|
||||
assert!(
|
||||
Path::new(&cwd).is_absolute(),
|
||||
"Working directory must be an absolute path: {}",
|
||||
cwd
|
||||
);
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
cwd,
|
||||
mcp_servers,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC request.
|
||||
pub fn to_jsonrpc(&self, id: u64) -> JsonRpcRequest {
|
||||
JsonRpcRequest::new(
|
||||
id,
|
||||
"session/load",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize SessionLoadRequest")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from session/load.
|
||||
///
|
||||
/// The response itself is empty (null in JSON), but the agent will send
|
||||
/// session/update notifications to replay the conversation history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionLoadResponse;
|
||||
|
||||
impl SessionLoadResponse {
|
||||
/// Parse from JSON-RPC result.
|
||||
pub fn from_jsonrpc(_result: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(SessionLoadResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to change session mode.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionSetModeRequest {
|
||||
/// Session ID to update.
|
||||
pub session_id: String,
|
||||
/// Mode to switch to (e.g., "code", "chat").
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
impl SessionSetModeRequest {
|
||||
/// Create a new session/set_mode request.
|
||||
pub fn new(session_id: impl Into<String>, mode: impl Into<String>) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
mode: mode.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC request.
|
||||
pub fn to_jsonrpc(&self, id: u64) -> JsonRpcRequest {
|
||||
JsonRpcRequest::new(
|
||||
id,
|
||||
"session/set_mode",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize SessionSetModeRequest")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from session/set_mode (empty).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionSetModeResponse;
|
||||
|
||||
impl SessionSetModeResponse {
|
||||
/// Parse from JSON-RPC result.
|
||||
pub fn from_jsonrpc(_result: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(SessionSetModeResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to change session model (UNSTABLE in spec).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionSetModelRequest {
|
||||
/// Session ID to update.
|
||||
pub session_id: String,
|
||||
/// Model identifier to switch to.
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl SessionSetModelRequest {
|
||||
/// Create a new session/set_model request.
|
||||
pub fn new(session_id: impl Into<String>, model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
model: model.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC request.
|
||||
pub fn to_jsonrpc(&self, id: u64) -> JsonRpcRequest {
|
||||
JsonRpcRequest::new(
|
||||
id,
|
||||
"session/set_model",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize SessionSetModelRequest")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from session/set_model (empty).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionSetModelResponse;
|
||||
|
||||
impl SessionSetModelResponse {
|
||||
/// Parse from JSON-RPC result.
|
||||
pub fn from_jsonrpc(_result: &serde_json::Value) -> Result<Self, serde_json::Error> {
|
||||
Ok(SessionSetModelResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification to cancel an ongoing prompt turn.
|
||||
///
|
||||
/// This is a notification (no response expected). The agent should stop
|
||||
/// LLM requests and tool executions, then respond to the original
|
||||
/// session/prompt with stopReason: cancelled.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionCancelNotification {
|
||||
/// Session ID to cancel.
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
impl SessionCancelNotification {
|
||||
/// Create a new session/cancel notification.
|
||||
pub fn new(session_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to JSON-RPC notification (no id field).
|
||||
pub fn to_jsonrpc(&self) -> JsonRpcRequest {
|
||||
JsonRpcRequest::notification(
|
||||
"session/cancel",
|
||||
Some(serde_json::to_value(self).expect("Failed to serialize SessionCancelNotification")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate MCP server configurations against agent capabilities.
|
||||
///
|
||||
/// Returns an error if any MCP server uses a transport that the agent
|
||||
/// doesn't support.
|
||||
pub fn validate_mcp_servers(
|
||||
mcp_servers: &[McpServer],
|
||||
agent_capabilities: &AgentCapabilities,
|
||||
) -> Result<(), String> {
|
||||
let mcp_caps = agent_capabilities.mcp.as_ref();
|
||||
|
||||
for server in mcp_servers {
|
||||
match server {
|
||||
McpServer::Stdio { .. } => {
|
||||
// Stdio is always supported
|
||||
}
|
||||
McpServer::Http { .. } => {
|
||||
let supported = mcp_caps
|
||||
.and_then(|caps| caps.http)
|
||||
.unwrap_or(false);
|
||||
if !supported {
|
||||
return Err(format!(
|
||||
"Agent does not support HTTP MCP servers (server: {})",
|
||||
match server {
|
||||
McpServer::Http { name, .. } => name,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
McpServer::Sse { .. } => {
|
||||
let supported = mcp_caps
|
||||
.and_then(|caps| caps.sse)
|
||||
.unwrap_or(false);
|
||||
if !supported {
|
||||
return Err(format!(
|
||||
"Agent does not support SSE MCP servers (server: {})",
|
||||
match server {
|
||||
McpServer::Sse { name, .. } => name,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new session.
|
||||
///
|
||||
/// Sends a session/new request to the agent and returns the session ID.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `cwd` - Working directory (absolute path)
|
||||
/// * `mcp_servers` - MCP servers to configure
|
||||
/// * `agent_capabilities` - Agent capabilities (for validation)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The session ID of the created session.
|
||||
pub async fn session_new(
|
||||
transport: &mut dyn Transport,
|
||||
cwd: impl Into<String>,
|
||||
mcp_servers: Vec<McpServer>,
|
||||
agent_capabilities: &AgentCapabilities,
|
||||
) -> Result<String, TransportError> {
|
||||
// Validate MCP servers
|
||||
validate_mcp_servers(&mcp_servers, agent_capabilities)
|
||||
.map_err(|e| TransportError::Other(e))?;
|
||||
|
||||
// Create request
|
||||
let request = SessionNewRequest::new(cwd, mcp_servers);
|
||||
let jsonrpc_request = request.to_jsonrpc(2); // Use ID 2 (after initialize which uses 1)
|
||||
|
||||
// Send request
|
||||
let result = transport.send_request(jsonrpc_request).await?;
|
||||
|
||||
// Handle response
|
||||
match result {
|
||||
JsonRpcResult::Success(response) => {
|
||||
let session_response = SessionNewResponse::from_jsonrpc(&response.result)
|
||||
.map_err(|e| TransportError::SerializationError(e))?;
|
||||
Ok(session_response.session_id)
|
||||
}
|
||||
JsonRpcResult::Error(error_response) => {
|
||||
Err(TransportError::JsonRpcError(error_response.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load an existing session.
|
||||
///
|
||||
/// Sends a session/load request to the agent. The agent will respond with
|
||||
/// an empty response, then send session/update notifications to replay
|
||||
/// the conversation history.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `session_id` - ID of the session to load
|
||||
/// * `cwd` - Working directory (absolute path)
|
||||
/// * `mcp_servers` - MCP servers to configure
|
||||
/// * `agent_capabilities` - Agent capabilities (for validation)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Ok(()) if the session load was initiated successfully. History will be
|
||||
/// replayed via session/update notifications.
|
||||
pub async fn session_load(
|
||||
transport: &mut dyn Transport,
|
||||
session_id: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
mcp_servers: Vec<McpServer>,
|
||||
agent_capabilities: &AgentCapabilities,
|
||||
) -> Result<(), TransportError> {
|
||||
// Check if agent supports load_session
|
||||
if !agent_capabilities.supports_load_session() {
|
||||
return Err(TransportError::Other(
|
||||
"Agent does not support session loading".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate MCP servers
|
||||
validate_mcp_servers(&mcp_servers, agent_capabilities)
|
||||
.map_err(|e| TransportError::Other(e))?;
|
||||
|
||||
// Create request
|
||||
let request = SessionLoadRequest::new(session_id, cwd, mcp_servers);
|
||||
let jsonrpc_request = request.to_jsonrpc(3); // Use next available ID
|
||||
|
||||
// Send request
|
||||
let result = transport.send_request(jsonrpc_request).await?;
|
||||
|
||||
// Handle response
|
||||
match result {
|
||||
JsonRpcResult::Success(_) => Ok(()),
|
||||
JsonRpcResult::Error(error_response) => {
|
||||
Err(TransportError::JsonRpcError(error_response.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the session mode.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `session_id` - ID of the session to update
|
||||
/// * `mode` - Mode to switch to (e.g., "code", "chat")
|
||||
pub async fn session_set_mode(
|
||||
transport: &mut dyn Transport,
|
||||
session_id: impl Into<String>,
|
||||
mode: impl Into<String>,
|
||||
) -> Result<(), TransportError> {
|
||||
let request = SessionSetModeRequest::new(session_id, mode);
|
||||
let jsonrpc_request = request.to_jsonrpc(4); // Use next available ID
|
||||
|
||||
let result = transport.send_request(jsonrpc_request).await?;
|
||||
|
||||
match result {
|
||||
JsonRpcResult::Success(_) => Ok(()),
|
||||
JsonRpcResult::Error(error_response) => {
|
||||
Err(TransportError::JsonRpcError(error_response.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the session model (UNSTABLE).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `session_id` - ID of the session to update
|
||||
/// * `model` - Model identifier to switch to
|
||||
pub async fn session_set_model(
|
||||
transport: &mut dyn Transport,
|
||||
session_id: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
) -> Result<(), TransportError> {
|
||||
let request = SessionSetModelRequest::new(session_id, model);
|
||||
let jsonrpc_request = request.to_jsonrpc(5); // Use next available ID
|
||||
|
||||
let result = transport.send_request(jsonrpc_request).await?;
|
||||
|
||||
match result {
|
||||
JsonRpcResult::Success(_) => Ok(()),
|
||||
JsonRpcResult::Error(error_response) => {
|
||||
Err(TransportError::JsonRpcError(error_response.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel an ongoing prompt turn.
|
||||
///
|
||||
/// Sends a session/cancel notification (no response expected). The agent
|
||||
/// should stop LLM requests and tool executions, then respond to the
|
||||
/// original session/prompt with stopReason: cancelled.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `transport` - The transport to use for communication
|
||||
/// * `session_id` - ID of the session to cancel
|
||||
pub async fn session_cancel(
|
||||
transport: &mut dyn Transport,
|
||||
session_id: impl Into<String>,
|
||||
) -> Result<(), TransportError> {
|
||||
let notification = SessionCancelNotification::new(session_id);
|
||||
let jsonrpc_request = notification.to_jsonrpc();
|
||||
|
||||
// Convert the notification-style request to a JsonRpcNotification
|
||||
let jsonrpc_notification = crate::acp::transport::JsonRpcNotification {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: jsonrpc_request.method,
|
||||
params: jsonrpc_request.params,
|
||||
};
|
||||
|
||||
// Send notification (no response expected)
|
||||
transport.send_notification(jsonrpc_notification).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::acp::connector_state::{
|
||||
EnvVariable, McpCapabilities, SessionState,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_mcp_server_serialization() {
|
||||
let server = McpServer::Stdio {
|
||||
name: "test-server".to_string(),
|
||||
command: "test-cmd".to_string(),
|
||||
args: vec!["arg1".to_string()],
|
||||
env: vec![EnvVariable {
|
||||
name: "VAR".to_string(),
|
||||
value: "value".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&server).unwrap();
|
||||
let deserialized: McpServer = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(server, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_new_request() {
|
||||
let request = SessionNewRequest::new("/absolute/path", vec![]);
|
||||
|
||||
assert_eq!(request.cwd, "/absolute/path");
|
||||
assert_eq!(request.mcp_servers.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Working directory must be an absolute path")]
|
||||
fn test_session_new_request_relative_path() {
|
||||
SessionNewRequest::new("relative/path", vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_new_request_serialization() {
|
||||
let request = SessionNewRequest::new("/test/path", vec![]);
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
let deserialized: SessionNewRequest = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(request, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_load_request() {
|
||||
let request = SessionLoadRequest::new("session-123", "/absolute/path", vec![]);
|
||||
|
||||
assert_eq!(request.session_id, "session-123");
|
||||
assert_eq!(request.cwd, "/absolute/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_set_mode_request() {
|
||||
let request = SessionSetModeRequest::new("session-123", "code");
|
||||
|
||||
assert_eq!(request.session_id, "session-123");
|
||||
assert_eq!(request.mode, "code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_cancel_notification() {
|
||||
let notification = SessionCancelNotification::new("session-123");
|
||||
|
||||
assert_eq!(notification.session_id, "session-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_state() {
|
||||
let mut state = SessionState::new("session-123".to_string(), "/path".to_string(), vec![]);
|
||||
|
||||
assert!(state.is_ready());
|
||||
assert!(!state.prompt_in_progress);
|
||||
|
||||
state.start_prompt();
|
||||
assert!(!state.is_ready());
|
||||
assert!(state.prompt_in_progress);
|
||||
|
||||
state.complete_prompt();
|
||||
assert!(state.is_ready());
|
||||
assert!(!state.prompt_in_progress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_state_cancellation() {
|
||||
let mut state = SessionState::new("session-123".to_string(), "/path".to_string(), vec![]);
|
||||
|
||||
state.start_prompt();
|
||||
state.start_cancellation();
|
||||
|
||||
assert!(!state.is_ready());
|
||||
assert!(state.cancelling);
|
||||
|
||||
state.complete_prompt();
|
||||
assert!(state.is_ready());
|
||||
assert!(!state.cancelling);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_state_loading() {
|
||||
let mut state = SessionState::new("session-123".to_string(), "/path".to_string(), vec![]);
|
||||
|
||||
state.start_loading();
|
||||
assert!(!state.is_ready());
|
||||
assert!(state.loading);
|
||||
|
||||
state.complete_loading();
|
||||
assert!(state.is_ready());
|
||||
assert!(!state.loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mcp_servers_stdio() {
|
||||
let servers = vec![McpServer::Stdio {
|
||||
name: "test".to_string(),
|
||||
command: "cmd".to_string(),
|
||||
args: vec![],
|
||||
env: vec![],
|
||||
}];
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: None,
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Stdio is always supported
|
||||
assert!(validate_mcp_servers(&servers, &caps).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mcp_servers_http_unsupported() {
|
||||
let servers = vec![McpServer::Http {
|
||||
name: "test".to_string(),
|
||||
url: "http://localhost".to_string(),
|
||||
headers: vec![],
|
||||
}];
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: None,
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// HTTP not supported
|
||||
assert!(validate_mcp_servers(&servers, &caps).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mcp_servers_http_supported() {
|
||||
let servers = vec![McpServer::Http {
|
||||
name: "test".to_string(),
|
||||
url: "http://localhost".to_string(),
|
||||
headers: vec![],
|
||||
}];
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: None,
|
||||
mcp: Some(McpCapabilities {
|
||||
http: Some(true),
|
||||
sse: None,
|
||||
}),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// HTTP supported
|
||||
assert!(validate_mcp_servers(&servers, &caps).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mcp_servers_sse_unsupported() {
|
||||
let servers = vec![McpServer::Sse {
|
||||
name: "test".to_string(),
|
||||
url: "http://localhost/events".to_string(),
|
||||
headers: vec![],
|
||||
}];
|
||||
|
||||
let caps = AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: None,
|
||||
mcp: Some(McpCapabilities {
|
||||
http: Some(true),
|
||||
sse: None,
|
||||
}),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// SSE not supported
|
||||
assert!(validate_mcp_servers(&servers, &caps).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Stop reason handling for ACP.
|
||||
//!
|
||||
//! This module implements interpretation and action determination for
|
||||
//! different stop reasons (end_turn, max_tokens, max_turn_requests, refusal, cancelled).
|
||||
|
||||
use crate::acp::protocol::prompt::StopReason;
|
||||
|
||||
/// Action to take based on a stop reason.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StopReasonAction {
|
||||
/// Normal completion, no special action needed.
|
||||
Complete,
|
||||
/// Show a warning to the user with optional continuation.
|
||||
ShowWarning(String),
|
||||
/// Show an error to the user.
|
||||
ShowError(String),
|
||||
/// Show an info message to the user.
|
||||
ShowInfo(String),
|
||||
}
|
||||
|
||||
/// Interpret a stop reason and determine the appropriate action.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `stop_reason` - The stop reason from the agent
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The action that should be taken in response to this stop reason.
|
||||
pub fn handle_stop_reason(stop_reason: StopReason) -> StopReasonAction {
|
||||
match stop_reason {
|
||||
StopReason::EndTurn => {
|
||||
// Normal completion, no special action needed
|
||||
StopReasonAction::Complete
|
||||
}
|
||||
StopReason::MaxTokens => {
|
||||
// Hit token limit, offer to continue
|
||||
StopReasonAction::ShowWarning(
|
||||
"Response truncated due to token limit. You can continue the conversation to get more output.".to_string()
|
||||
)
|
||||
}
|
||||
StopReason::MaxTurnRequests => {
|
||||
// Too many LLM requests in turn, offer to continue
|
||||
StopReasonAction::ShowWarning(
|
||||
"Maximum turn requests reached. The agent made many requests. You can continue if needed.".to_string()
|
||||
)
|
||||
}
|
||||
StopReason::Refusal => {
|
||||
// Agent refused to continue
|
||||
StopReasonAction::ShowError(
|
||||
"The agent refused to continue with this request.".to_string()
|
||||
)
|
||||
}
|
||||
StopReason::Cancelled => {
|
||||
// User cancelled, show confirmation
|
||||
StopReasonAction::ShowInfo("Prompt cancelled.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a stop reason indicates the turn can be continued.
|
||||
///
|
||||
/// Some stop reasons (max_tokens, max_turn_requests) indicate the agent
|
||||
/// stopped due to limits but could continue if prompted again.
|
||||
pub fn is_continuable(stop_reason: StopReason) -> bool {
|
||||
matches!(
|
||||
stop_reason,
|
||||
StopReason::MaxTokens | StopReason::MaxTurnRequests
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if a stop reason indicates an error condition.
|
||||
pub fn is_error(stop_reason: StopReason) -> bool {
|
||||
matches!(stop_reason, StopReason::Refusal)
|
||||
}
|
||||
|
||||
/// Get a user-facing message for a stop reason.
|
||||
pub fn stop_reason_message(stop_reason: StopReason) -> String {
|
||||
match handle_stop_reason(stop_reason) {
|
||||
StopReasonAction::Complete => "Turn completed".to_string(),
|
||||
StopReasonAction::ShowWarning(msg) => msg,
|
||||
StopReasonAction::ShowError(msg) => msg,
|
||||
StopReasonAction::ShowInfo(msg) => msg,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_end_turn_action() {
|
||||
let action = handle_stop_reason(StopReason::EndTurn);
|
||||
assert_eq!(action, StopReasonAction::Complete);
|
||||
assert!(!is_continuable(StopReason::EndTurn));
|
||||
assert!(!is_error(StopReason::EndTurn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_tokens_action() {
|
||||
let action = handle_stop_reason(StopReason::MaxTokens);
|
||||
assert!(matches!(action, StopReasonAction::ShowWarning(_)));
|
||||
assert!(is_continuable(StopReason::MaxTokens));
|
||||
assert!(!is_error(StopReason::MaxTokens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_turn_requests_action() {
|
||||
let action = handle_stop_reason(StopReason::MaxTurnRequests);
|
||||
assert!(matches!(action, StopReasonAction::ShowWarning(_)));
|
||||
assert!(is_continuable(StopReason::MaxTurnRequests));
|
||||
assert!(!is_error(StopReason::MaxTurnRequests));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_refusal_action() {
|
||||
let action = handle_stop_reason(StopReason::Refusal);
|
||||
assert!(matches!(action, StopReasonAction::ShowError(_)));
|
||||
assert!(!is_continuable(StopReason::Refusal));
|
||||
assert!(is_error(StopReason::Refusal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancelled_action() {
|
||||
let action = handle_stop_reason(StopReason::Cancelled);
|
||||
assert!(matches!(action, StopReasonAction::ShowInfo(_)));
|
||||
assert!(!is_continuable(StopReason::Cancelled));
|
||||
assert!(!is_error(StopReason::Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_reason_messages() {
|
||||
let reasons = vec![
|
||||
StopReason::EndTurn,
|
||||
StopReason::MaxTokens,
|
||||
StopReason::MaxTurnRequests,
|
||||
StopReason::Refusal,
|
||||
StopReason::Cancelled,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
let message = stop_reason_message(reason);
|
||||
assert!(!message.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Streaming update handling for ACP.
|
||||
//!
|
||||
//! This module implements session/update notification handling, including:
|
||||
//! - Session update dispatcher
|
||||
//! - Message and thought chunk accumulation
|
||||
//! - Tool call tracking and updates
|
||||
//! - Plan and command updates
|
||||
//! - User message chunks (for session replay)
|
||||
|
||||
use crate::acp::protocol::prompt::ContentBlock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Notification of a session update from the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionUpdateNotification {
|
||||
/// Session ID this update belongs to.
|
||||
pub session_id: String,
|
||||
/// The update itself.
|
||||
#[serde(flatten)]
|
||||
pub update: SessionUpdate,
|
||||
}
|
||||
|
||||
/// Session update type.
|
||||
///
|
||||
/// These are sent from the agent to the client during a prompt turn
|
||||
/// to provide real-time updates on progress.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "sessionUpdate", rename_all = "snake_case")]
|
||||
pub enum SessionUpdate {
|
||||
/// Agent message chunk (assistant output).
|
||||
AgentMessageChunk {
|
||||
content: ContentBlock,
|
||||
},
|
||||
/// Agent thought/reasoning chunk.
|
||||
AgentThoughtChunk {
|
||||
content: ContentBlock,
|
||||
},
|
||||
/// User message chunk (for session replay).
|
||||
UserMessageChunk {
|
||||
content: ContentBlock,
|
||||
},
|
||||
/// New tool call initiated.
|
||||
ToolCall {
|
||||
tool_call_id: String,
|
||||
title: String,
|
||||
kind: ToolKind,
|
||||
status: ToolCallStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
location: Option<ToolCallLocation>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<Vec<ToolCallContent>>,
|
||||
},
|
||||
/// Update to existing tool call.
|
||||
ToolCallUpdate {
|
||||
tool_call_id: String,
|
||||
status: ToolCallStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<Vec<ToolCallContent>>,
|
||||
},
|
||||
/// Agent execution plan.
|
||||
Plan {
|
||||
entries: Vec<PlanEntry>,
|
||||
},
|
||||
/// Available commands update.
|
||||
AvailableCommandsUpdate {
|
||||
commands: Vec<Command>,
|
||||
},
|
||||
/// Current mode update (agent-initiated mode change).
|
||||
///
|
||||
/// Sent when the agent changes its own mode (e.g., exiting plan mode via a tool).
|
||||
/// Per ACP spec, the field is `modeId` (camelCase).
|
||||
CurrentModeUpdate {
|
||||
#[serde(rename = "modeId")]
|
||||
mode_id: String,
|
||||
},
|
||||
/// Current model update (agent-initiated model change).
|
||||
///
|
||||
/// UNSTABLE in ACP spec - may be sent when agent changes model.
|
||||
/// Included for forward compatibility.
|
||||
CurrentModelUpdate {
|
||||
#[serde(rename = "modelId")]
|
||||
model_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Kind of tool being invoked.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolKind {
|
||||
/// File read operations.
|
||||
Read,
|
||||
/// File write/modification operations.
|
||||
Edit,
|
||||
/// Search operations (glob, grep, ls).
|
||||
Search,
|
||||
/// Terminal/command execution.
|
||||
Execute,
|
||||
/// Agent internal reasoning.
|
||||
Think,
|
||||
/// Other/unknown tool type.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Status of a tool call.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallStatus {
|
||||
/// Waiting to start.
|
||||
Pending,
|
||||
/// Currently executing.
|
||||
InProgress,
|
||||
/// Completed successfully.
|
||||
Completed,
|
||||
/// Failed with error.
|
||||
Failed,
|
||||
/// Cancelled by user.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Location information for a tool call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ToolCallLocation {
|
||||
/// File path.
|
||||
pub path: String,
|
||||
/// Line number (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line: Option<u32>,
|
||||
}
|
||||
|
||||
/// Content of a tool call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolCallContent {
|
||||
/// Regular content block.
|
||||
Content {
|
||||
content: ContentBlock,
|
||||
},
|
||||
/// File diff.
|
||||
Diff {
|
||||
path: String,
|
||||
diff: String,
|
||||
},
|
||||
/// Terminal output reference.
|
||||
Terminal {
|
||||
terminal_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Plan entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PlanEntry {
|
||||
/// Plan step content.
|
||||
pub content: String,
|
||||
/// Priority level.
|
||||
pub priority: String,
|
||||
/// Status of this step.
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Command available to the agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Command {
|
||||
/// Command name.
|
||||
pub name: String,
|
||||
/// Optional description.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// State for accumulating message chunks.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MessageAccumulator {
|
||||
/// Accumulated content blocks.
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
/// Whether this is a thought/reasoning block.
|
||||
pub is_thought: bool,
|
||||
}
|
||||
|
||||
impl MessageAccumulator {
|
||||
/// Create a new message accumulator.
|
||||
pub fn new(is_thought: bool) -> Self {
|
||||
Self {
|
||||
blocks: Vec::new(),
|
||||
is_thought,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a content block to the accumulator.
|
||||
pub fn add_block(&mut self, block: ContentBlock) {
|
||||
self.blocks.push(block);
|
||||
}
|
||||
|
||||
/// Get accumulated text (concatenates all text blocks).
|
||||
pub fn get_text(&self) -> String {
|
||||
self.blocks
|
||||
.iter()
|
||||
.filter_map(|block| {
|
||||
if let ContentBlock::Text { text, .. } = block {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Clear the accumulator.
|
||||
pub fn clear(&mut self) {
|
||||
self.blocks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool call tracking state.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolCallInfo {
|
||||
/// Unique ID for this tool call.
|
||||
pub tool_call_id: String,
|
||||
/// Human-readable title.
|
||||
pub title: String,
|
||||
/// Kind of tool.
|
||||
pub kind: ToolKind,
|
||||
/// Current status.
|
||||
pub status: ToolCallStatus,
|
||||
/// Location (file/line) if applicable.
|
||||
pub location: Option<ToolCallLocation>,
|
||||
/// Content accumulated so far.
|
||||
pub content: Vec<ToolCallContent>,
|
||||
}
|
||||
|
||||
impl ToolCallInfo {
|
||||
/// Create a new tool call tracking object.
|
||||
pub fn new(
|
||||
tool_call_id: String,
|
||||
title: String,
|
||||
kind: ToolKind,
|
||||
status: ToolCallStatus,
|
||||
location: Option<ToolCallLocation>,
|
||||
content: Option<Vec<ToolCallContent>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_call_id,
|
||||
title,
|
||||
kind,
|
||||
status,
|
||||
location,
|
||||
content: content.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the tool call with new status and optionally new content.
|
||||
pub fn update(&mut self, status: ToolCallStatus, new_content: Option<Vec<ToolCallContent>>) {
|
||||
self.status = status;
|
||||
if let Some(content) = new_content {
|
||||
self.content.extend(content);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the tool call is in a terminal state.
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
ToolCallStatus::Completed | ToolCallStatus::Failed | ToolCallStatus::Cancelled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_session_update_agent_message_chunk() {
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
annotations: None,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains("\"sessionUpdate\":\"agent_message_chunk\""));
|
||||
assert!(json.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_tool_call() {
|
||||
let update = SessionUpdate::ToolCall {
|
||||
tool_call_id: "tool-1".to_string(),
|
||||
title: "Read file".to_string(),
|
||||
kind: ToolKind::Read,
|
||||
status: ToolCallStatus::Pending,
|
||||
location: Some(ToolCallLocation {
|
||||
path: "/path/to/file.txt".to_string(),
|
||||
line: Some(42),
|
||||
}),
|
||||
content: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains("\"sessionUpdate\":\"tool_call\""));
|
||||
assert!(json.contains("tool-1"));
|
||||
assert!(json.contains("Read file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_kind_serialization() {
|
||||
let kinds = vec![
|
||||
ToolKind::Read,
|
||||
ToolKind::Edit,
|
||||
ToolKind::Search,
|
||||
ToolKind::Execute,
|
||||
ToolKind::Think,
|
||||
ToolKind::Other,
|
||||
];
|
||||
|
||||
for kind in kinds {
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
let deserialized: ToolKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(kind, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_status_serialization() {
|
||||
let statuses = vec![
|
||||
ToolCallStatus::Pending,
|
||||
ToolCallStatus::InProgress,
|
||||
ToolCallStatus::Completed,
|
||||
ToolCallStatus::Failed,
|
||||
ToolCallStatus::Cancelled,
|
||||
];
|
||||
|
||||
for status in statuses {
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
let deserialized: ToolCallStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(status, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_diff() {
|
||||
let content = ToolCallContent::Diff {
|
||||
path: "/path/to/file.txt".to_string(),
|
||||
diff: "@@ -1,1 +1,1 @@\n-old\n+new".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&content).unwrap();
|
||||
assert!(json.contains("\"type\":\"diff\""));
|
||||
assert!(json.contains("/path/to/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_accumulator() {
|
||||
let mut acc = MessageAccumulator::new(false);
|
||||
|
||||
acc.add_block(ContentBlock::Text {
|
||||
text: "Hello ".to_string(),
|
||||
annotations: None,
|
||||
});
|
||||
acc.add_block(ContentBlock::Text {
|
||||
text: "world".to_string(),
|
||||
annotations: None,
|
||||
});
|
||||
|
||||
assert_eq!(acc.get_text(), "Hello world");
|
||||
assert!(!acc.is_thought);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_info_update() {
|
||||
let mut info = ToolCallInfo::new(
|
||||
"tool-1".to_string(),
|
||||
"Test".to_string(),
|
||||
ToolKind::Read,
|
||||
ToolCallStatus::Pending,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!info.is_terminal());
|
||||
|
||||
info.update(ToolCallStatus::InProgress, None);
|
||||
assert_eq!(info.status, ToolCallStatus::InProgress);
|
||||
assert!(!info.is_terminal());
|
||||
|
||||
info.update(ToolCallStatus::Completed, None);
|
||||
assert_eq!(info.status, ToolCallStatus::Completed);
|
||||
assert!(info.is_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_entry() {
|
||||
let entry = PlanEntry {
|
||||
content: "Step 1: Do something".to_string(),
|
||||
priority: "high".to_string(),
|
||||
status: "pending".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
let deserialized: PlanEntry = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(entry, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command() {
|
||||
let command = Command {
|
||||
name: "/help".to_string(),
|
||||
description: Some("Show help".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&command).unwrap();
|
||||
let deserialized: Command = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(command, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_notification_current_mode() {
|
||||
let notification = SessionUpdateNotification {
|
||||
session_id: "session-123".to_string(),
|
||||
update: SessionUpdate::CurrentModeUpdate {
|
||||
mode_id: "code".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(¬ification).unwrap();
|
||||
assert!(json.contains("session-123"));
|
||||
assert!(json.contains("\"sessionUpdate\":\"current_mode_update\""));
|
||||
// Verify camelCase field name per ACP spec
|
||||
assert!(json.contains("\"modeId\":\"code\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_notification_current_model() {
|
||||
let notification = SessionUpdateNotification {
|
||||
session_id: "session-123".to_string(),
|
||||
update: SessionUpdate::CurrentModelUpdate {
|
||||
model_id: "sonnet".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(¬ification).unwrap();
|
||||
assert!(json.contains("session-123"));
|
||||
assert!(json.contains("\"sessionUpdate\":\"current_model_update\""));
|
||||
// Verify camelCase field name per ACP spec
|
||||
assert!(json.contains("\"modelId\":\"sonnet\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_mode_update_deserialization() {
|
||||
// Test deserialization from ACP spec format
|
||||
let json = r#"{
|
||||
"sessionUpdate": "current_mode_update",
|
||||
"modeId": "plan"
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::CurrentModeUpdate { mode_id } => {
|
||||
assert_eq!(mode_id, "plan");
|
||||
}
|
||||
_ => panic!("Expected CurrentModeUpdate"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_model_update_deserialization() {
|
||||
// Test deserialization from ACP spec format (forward compatibility)
|
||||
let json = r#"{
|
||||
"sessionUpdate": "current_model_update",
|
||||
"modelId": "haiku"
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::CurrentModelUpdate { model_id } => {
|
||||
assert_eq!(model_id, "haiku");
|
||||
}
|
||||
_ => panic!("Expected CurrentModelUpdate"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Multi-line JSON reader for stdio-based transports.
|
||||
//!
|
||||
//! Provides resilient JSON-RPC message reading that handles agents or clients
|
||||
//! sending multi-line (pretty-printed) JSON instead of strict single-line
|
||||
//! JSON-RPC. Uses `serde_json::StreamDeserializer` to correctly extract
|
||||
//! complete JSON values from a buffer, even when they span multiple lines
|
||||
//! or when multiple values are concatenated.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! Both sides of a JSON-RPC stdio connection can use this:
|
||||
//! - **Client side** (`connectors::acp::transport::StdioTransport`): reading agent stdout
|
||||
//! - **Server side** (`dirigate`): reading client stdin
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use dirigent_core::acp::transport::json_reader::{JsonLineReader, ReadResult};
|
||||
//! use tokio::io::BufReader;
|
||||
//!
|
||||
//! let stdin = tokio::io::stdin();
|
||||
//! let mut reader = BufReader::new(stdin);
|
||||
//! let mut json_reader = JsonLineReader::new();
|
||||
//!
|
||||
//! loop {
|
||||
//! match json_reader.read_message(&mut reader).await {
|
||||
//! Ok(ReadResult::Message(msg)) => println!("Got: {}", msg),
|
||||
//! Ok(ReadResult::Eof) => break,
|
||||
//! Err(e) => eprintln!("Error: {}", e),
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufRead, AsyncBufReadExt};
|
||||
|
||||
/// Safety limit: max lines to buffer before giving up.
|
||||
const MAX_BUFFER_LINES: usize = 50_000;
|
||||
/// Safety limit: max buffer size in bytes (10 MB).
|
||||
const MAX_BUFFER_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Result of reading one JSON message from the stream.
|
||||
pub enum ReadResult {
|
||||
/// Successfully read a complete JSON message.
|
||||
Message(Value),
|
||||
/// EOF reached (stream closed).
|
||||
Eof,
|
||||
}
|
||||
|
||||
/// Persistent state for a multi-line JSON reader.
|
||||
///
|
||||
/// Maintains a pending message buffer across `read_message` calls to handle
|
||||
/// cases where the stream deserializer extracts two messages from one buffer.
|
||||
pub struct JsonLineReader {
|
||||
/// Pending message from a previous parse that found two values in one buffer.
|
||||
pending: Option<Value>,
|
||||
/// Incomplete remainder from a previous parse where a complete JSON message
|
||||
/// was followed by the start of another message that wasn't yet complete.
|
||||
pending_remainder: Option<String>,
|
||||
}
|
||||
|
||||
impl JsonLineReader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: None,
|
||||
pending_remainder: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the next complete JSON message from the given buffered reader.
|
||||
///
|
||||
/// Handles:
|
||||
/// - Single-line JSON (normal case)
|
||||
/// - Multi-line / pretty-printed JSON
|
||||
/// - Two concatenated JSON objects in the buffer
|
||||
/// - Non-JSON garbage (returns error immediately)
|
||||
///
|
||||
/// The reader is borrowed mutably for each call but state is maintained
|
||||
/// in `self` across calls (for pending messages).
|
||||
pub async fn read_message<R: AsyncBufRead + Unpin>(
|
||||
&mut self,
|
||||
reader: &mut R,
|
||||
) -> Result<ReadResult, String> {
|
||||
// Return pending message from previous parse if available
|
||||
if let Some(msg) = self.pending.take() {
|
||||
tracing::debug!("Returning pending message from previous read_message() parse");
|
||||
return Ok(ReadResult::Message(msg));
|
||||
}
|
||||
|
||||
let mut json_buffer = String::new();
|
||||
let mut buffered_lines: usize = 0;
|
||||
|
||||
// Initialize buffer from pending remainder if available.
|
||||
if let Some(remainder) = self.pending_remainder.take() {
|
||||
tracing::debug!(
|
||||
remainder_bytes = remainder.len(),
|
||||
"Initializing buffer from pending remainder"
|
||||
);
|
||||
json_buffer = remainder;
|
||||
buffered_lines = 1;
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes_read = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read line: {}", e))?;
|
||||
|
||||
// EOF
|
||||
if bytes_read == 0 {
|
||||
if !json_buffer.is_empty() {
|
||||
tracing::warn!(
|
||||
buffer_lines = buffered_lines,
|
||||
buffer_bytes = json_buffer.len(),
|
||||
"EOF with incomplete multi-line JSON buffer"
|
||||
);
|
||||
}
|
||||
return Ok(ReadResult::Eof);
|
||||
}
|
||||
|
||||
// Skip empty lines when not buffering
|
||||
if line.trim().is_empty() && json_buffer.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Append to buffer
|
||||
if json_buffer.is_empty() {
|
||||
json_buffer = line;
|
||||
buffered_lines = 1;
|
||||
} else {
|
||||
json_buffer.push_str(&line);
|
||||
buffered_lines += 1;
|
||||
}
|
||||
|
||||
// Try to extract a complete JSON value
|
||||
if let Some((message, remainder)) = try_extract_json(&json_buffer) {
|
||||
if buffered_lines > 1 {
|
||||
tracing::warn!(
|
||||
lines = buffered_lines,
|
||||
bytes = json_buffer.len(),
|
||||
"Recovered multi-line JSON message"
|
||||
);
|
||||
}
|
||||
|
||||
// If there's leftover data, try to parse as a second message
|
||||
if !remainder.is_empty() {
|
||||
tracing::debug!(
|
||||
remainder_bytes = remainder.len(),
|
||||
"Buffer has remaining data after complete JSON"
|
||||
);
|
||||
if let Some((second_msg, second_remainder)) = try_extract_json(&remainder) {
|
||||
self.pending = Some(second_msg);
|
||||
if !second_remainder.is_empty() {
|
||||
tracing::debug!(
|
||||
remainder_bytes = second_remainder.len(),
|
||||
"Carrying over third message fragment as pending remainder"
|
||||
);
|
||||
self.pending_remainder = Some(second_remainder);
|
||||
}
|
||||
} else {
|
||||
// Incomplete second message — carry over instead of discarding
|
||||
tracing::debug!(
|
||||
remainder_bytes = remainder.len(),
|
||||
"Carrying over incomplete remainder for next read_message()"
|
||||
);
|
||||
self.pending_remainder = Some(remainder);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ReadResult::Message(message));
|
||||
}
|
||||
|
||||
// Buffer doesn't contain a complete JSON value yet.
|
||||
// Check if it could ever become valid JSON.
|
||||
let buffer_trimmed = json_buffer.trim_start();
|
||||
if !buffer_trimmed.starts_with('{') && !buffer_trimmed.starts_with('[') {
|
||||
return Err(format!(
|
||||
"Failed to parse JSON: expected JSON object. Line: {}",
|
||||
&buffer_trimmed[..buffer_trimmed.len().min(200)]
|
||||
));
|
||||
}
|
||||
|
||||
// Safety limits
|
||||
if buffered_lines >= MAX_BUFFER_LINES {
|
||||
return Err(format!(
|
||||
"Multi-line JSON message exceeded {} lines without completing",
|
||||
MAX_BUFFER_LINES
|
||||
));
|
||||
}
|
||||
if json_buffer.len() > MAX_BUFFER_BYTES {
|
||||
return Err(format!(
|
||||
"Multi-line JSON message exceeded {} bytes without completing",
|
||||
MAX_BUFFER_BYTES
|
||||
));
|
||||
}
|
||||
|
||||
if buffered_lines % 1000 == 0 {
|
||||
tracing::debug!(
|
||||
lines = buffered_lines,
|
||||
bytes = json_buffer.len(),
|
||||
"Still buffering multi-line JSON message..."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to extract a complete JSON value from the front of `buffer`.
|
||||
///
|
||||
/// Uses `serde_json::StreamDeserializer` to parse the first complete JSON value.
|
||||
/// Returns `Some((value, remaining))` if a value was found, where `remaining`
|
||||
/// is the unparsed tail of the buffer. Returns `None` if the buffer doesn't
|
||||
/// contain a complete JSON value yet.
|
||||
pub fn try_extract_json(buffer: &str) -> Option<(Value, String)> {
|
||||
let mut stream = serde_json::Deserializer::from_str(buffer).into_iter::<Value>();
|
||||
match stream.next() {
|
||||
Some(Ok(value)) => {
|
||||
let byte_offset = stream.byte_offset();
|
||||
let remaining = buffer[byte_offset..].trim_start().to_string();
|
||||
Some((value, remaining))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_single_json() {
|
||||
let input = r#"{"jsonrpc":"2.0","id":1,"result":{}}"#;
|
||||
let (value, remainder) = try_extract_json(input).unwrap();
|
||||
assert_eq!(value["id"], 1);
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_incomplete_returns_none() {
|
||||
assert!(try_extract_json(r#"{"id":1,"res"#).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_two_concatenated() {
|
||||
let input = r#"{"id":1,"result":{}}
|
||||
{"id":2,"result":{}}"#;
|
||||
let (first, remainder) = try_extract_json(input).unwrap();
|
||||
assert_eq!(first["id"], 1);
|
||||
let (second, remainder2) = try_extract_json(&remainder).unwrap();
|
||||
assert_eq!(second["id"], 2);
|
||||
assert!(remainder2.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_multiline_pretty_printed() {
|
||||
let input = "{\n \"id\": 1,\n \"result\": {}\n}";
|
||||
let (value, remainder) = try_extract_json(input).unwrap();
|
||||
assert_eq!(value["id"], 1);
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_garbage_returns_none() {
|
||||
assert!(try_extract_json("on-management\",\n\n div {").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_empty_returns_none() {
|
||||
assert!(try_extract_json("").is_none());
|
||||
assert!(try_extract_json(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_complete_followed_by_partial() {
|
||||
let input = r#"{"id":1,"result":{}}
|
||||
{"id":2,"res"#;
|
||||
let (first, remainder) = try_extract_json(input).unwrap();
|
||||
assert_eq!(first["id"], 1);
|
||||
assert!(try_extract_json(&remainder).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_trailing_whitespace() {
|
||||
let input = r#"{"id":1,"result":{}} "#;
|
||||
let (value, remainder) = try_extract_json(input).unwrap();
|
||||
assert_eq!(value["id"], 1);
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_escaped_newlines() {
|
||||
let input = r#"{"content":"line1\nline2\nline3"}"#;
|
||||
let (value, remainder) = try_extract_json(input).unwrap();
|
||||
assert_eq!(value["content"], "line1\nline2\nline3");
|
||||
assert!(remainder.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_single_line() {
|
||||
let input = b"{\"id\":1,\"result\":{}}\n";
|
||||
let mut cursor = std::io::Cursor::new(input.to_vec());
|
||||
let mut reader = JsonLineReader::new();
|
||||
match reader.read_message(&mut cursor).await.unwrap() {
|
||||
ReadResult::Message(msg) => assert_eq!(msg["id"], 1),
|
||||
ReadResult::Eof => panic!("Expected message, got EOF"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_multiline() {
|
||||
let input = b"{\n \"id\": 1,\n \"result\": {}\n}\n";
|
||||
let mut cursor = std::io::Cursor::new(input.to_vec());
|
||||
let mut reader = JsonLineReader::new();
|
||||
match reader.read_message(&mut cursor).await.unwrap() {
|
||||
ReadResult::Message(msg) => assert_eq!(msg["id"], 1),
|
||||
ReadResult::Eof => panic!("Expected message, got EOF"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_eof() {
|
||||
let input = b"";
|
||||
let mut cursor = std::io::Cursor::new(input.to_vec());
|
||||
let mut reader = JsonLineReader::new();
|
||||
match reader.read_message(&mut cursor).await.unwrap() {
|
||||
ReadResult::Message(_) => panic!("Expected EOF"),
|
||||
ReadResult::Eof => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_garbage_returns_error() {
|
||||
let input = b"not json at all\n";
|
||||
let mut cursor = std::io::Cursor::new(input.to_vec());
|
||||
let mut reader = JsonLineReader::new();
|
||||
let result = reader.read_message(&mut cursor).await;
|
||||
assert!(result.is_err());
|
||||
// Error should contain the raw line for forensics
|
||||
let err = result.err().unwrap();
|
||||
assert!(err.contains("not json at all"), "Error should contain raw line: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_carries_over_incomplete_remainder() {
|
||||
// Line 1 has a complete JSON object concatenated with the start of a second
|
||||
// (split at a JSON whitespace boundary — between tokens, not inside a string).
|
||||
// read_line returns `{"id":1}{"id":2,\n` (up to first newline).
|
||||
// try_extract_json extracts id:1, remainder `{"id":2,` is incomplete.
|
||||
// With the fix, this remainder is carried over as pending_remainder.
|
||||
// Second read_message() initializes json_buffer from it, reads the next line
|
||||
// `"result":{}}\n`, assembles `{"id":2,\n"result":{}}\n`, and extracts id:2.
|
||||
let input = b"{\"id\":1}{\"id\":2,\n\"result\":{}}\n";
|
||||
let mut cursor = std::io::Cursor::new(input.to_vec());
|
||||
let mut reader = JsonLineReader::new();
|
||||
|
||||
// First call: extracts id:1, carries over incomplete remainder
|
||||
match reader.read_message(&mut cursor).await.unwrap() {
|
||||
ReadResult::Message(msg) => assert_eq!(msg["id"], 1),
|
||||
ReadResult::Eof => panic!("Expected message 1, got EOF"),
|
||||
}
|
||||
|
||||
// Second call: assembles id:2 from remainder + next line
|
||||
match reader.read_message(&mut cursor).await.unwrap() {
|
||||
ReadResult::Message(msg) => assert_eq!(msg["id"], 2),
|
||||
ReadResult::Eof => panic!("Expected message 2, got EOF"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_remainder_multiline_recovery() {
|
||||
// Simulates the bug scenario: agent sends streaming notifications rapidly,
|
||||
// OS pipe buffering delivers a complete message concatenated with the start
|
||||
// of the next on the same line. The second message spans multiple lines.
|
||||
//
|
||||
// Line 1: complete notification + start of second notification (no newline between)
|
||||
// Line 2: continuation of second notification's JSON
|
||||
// Line 3: closing of second notification
|
||||
let msg1 = r#"{"jsonrpc":"2.0","method":"notify","params":{"ok":true}}"#;
|
||||
// Second message is split across lines at JSON-whitespace boundaries
|
||||
let input = format!(
|
||||
"{}{{\n\"jsonrpc\":\"2.0\",\n\"method\":\"update\",\"params\":{{\"content\":\"card data\"}}}}\n",
|
||||
msg1
|
||||
);
|
||||
let mut cursor = std::io::Cursor::new(input.into_bytes());
|
||||
let mut reader = JsonLineReader::new();
|
||||
|
||||
// First message
|
||||
match reader.read_message(&mut cursor).await.unwrap() {
|
||||
ReadResult::Message(msg) => {
|
||||
assert_eq!(msg["method"], "notify");
|
||||
assert_eq!(msg["params"]["ok"], true);
|
||||
}
|
||||
ReadResult::Eof => panic!("Expected message 1"),
|
||||
}
|
||||
|
||||
// Second message (assembled from remainder + subsequent lines)
|
||||
match reader.read_message(&mut cursor).await.unwrap() {
|
||||
ReadResult::Message(msg) => {
|
||||
assert_eq!(msg["method"], "update");
|
||||
assert_eq!(msg["params"]["content"], "card data");
|
||||
}
|
||||
ReadResult::Eof => panic!("Expected message 2"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Transport layer abstraction for ACP (Agent-Client Protocol).
|
||||
//!
|
||||
//! This module provides a unified interface for communicating with ACP agents
|
||||
//! over different transports (stdio, HTTP+SSE). It handles JSON-RPC message
|
||||
//! encoding/decoding and transport-specific connection management.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::pin::Pin;
|
||||
use thiserror::Error;
|
||||
|
||||
// Shared utilities for JSON-over-stdio transports
|
||||
pub mod json_reader;
|
||||
|
||||
/// JSON-RPC 2.0 request message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
pub jsonrpc: String, // Always "2.0"
|
||||
pub id: serde_json::Value, // Request ID (number, string, or null)
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcRequest {
|
||||
/// Create a new JSON-RPC request with auto-generated numeric ID.
|
||||
pub fn new(id: u64, method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Number(id.into()),
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new JSON-RPC request with string ID.
|
||||
pub fn new_with_string_id(
|
||||
id: impl Into<String>,
|
||||
method: impl Into<String>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::String(id.into()),
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a JSON-RPC notification (no ID field).
|
||||
///
|
||||
/// Notifications are fire-and-forget - no response is expected.
|
||||
pub fn notification(method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Null,
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 response message (success).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
pub jsonrpc: String, // Always "2.0"
|
||||
pub id: serde_json::Value, // Request ID (matches request)
|
||||
pub result: serde_json::Value,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 error response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcErrorResponse {
|
||||
pub jsonrpc: String, // Always "2.0"
|
||||
pub id: serde_json::Value, // Request ID (matches request)
|
||||
pub error: JsonRpcError,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 error object.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for JsonRpcError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "code {}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 notification message (no response expected).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcNotification {
|
||||
pub jsonrpc: String, // Always "2.0"
|
||||
pub method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcNotification {
|
||||
/// Create a new JSON-RPC notification.
|
||||
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-level errors.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransportError {
|
||||
#[error("Connection error: {0}")]
|
||||
ConnectionError(String),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
|
||||
#[error("JSON-RPC error: {0}")]
|
||||
JsonRpcError(JsonRpcError),
|
||||
|
||||
#[error("Transport closed")]
|
||||
Closed,
|
||||
|
||||
#[error("Timeout waiting for response")]
|
||||
Timeout,
|
||||
|
||||
#[error("Process exited unexpectedly")]
|
||||
ProcessExited,
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("SSE error: {0}")]
|
||||
SseError(String),
|
||||
|
||||
#[error("Other error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Transport state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransportState {
|
||||
/// Not yet connected.
|
||||
Disconnected,
|
||||
/// Currently connecting.
|
||||
Connecting,
|
||||
/// Connected and operational.
|
||||
Connected,
|
||||
/// Disconnected due to error.
|
||||
Error,
|
||||
}
|
||||
|
||||
|
||||
/// Result type for JSON-RPC operations.
|
||||
///
|
||||
/// This wraps either a successful response or an error response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum JsonRpcResult {
|
||||
Success(JsonRpcResponse),
|
||||
Error(JsonRpcErrorResponse),
|
||||
}
|
||||
|
||||
impl JsonRpcResult {
|
||||
/// Convert to Result, mapping error responses to TransportError.
|
||||
pub fn into_result(self) -> Result<JsonRpcResponse, TransportError> {
|
||||
match self {
|
||||
JsonRpcResult::Success(response) => Ok(response),
|
||||
JsonRpcResult::Error(error_response) => Err(TransportError::JsonRpcError(error_response.error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the result value if successful.
|
||||
pub fn result(&self) -> Option<&serde_json::Value> {
|
||||
match self {
|
||||
JsonRpcResult::Success(response) => Some(&response.result),
|
||||
JsonRpcResult::Error(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the error if this is an error response.
|
||||
pub fn error(&self) -> Option<&JsonRpcError> {
|
||||
match self {
|
||||
JsonRpcResult::Success(_) => None,
|
||||
JsonRpcResult::Error(response) => Some(&response.error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a success response.
|
||||
pub fn is_success(&self) -> bool {
|
||||
matches!(self, JsonRpcResult::Success(_))
|
||||
}
|
||||
|
||||
/// Check if this is an error response.
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(self, JsonRpcResult::Error(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Notification stream type.
|
||||
pub type NotificationStream = Pin<Box<dyn Stream<Item = JsonRpcNotification> + Send>>;
|
||||
|
||||
/// Transport trait for ACP communication.
|
||||
///
|
||||
/// This trait abstracts over stdio and HTTP transports, providing a unified
|
||||
/// interface for sending JSON-RPC requests and receiving notifications.
|
||||
#[async_trait]
|
||||
pub trait Transport: Send + Sync {
|
||||
/// Send a JSON-RPC request and wait for the response.
|
||||
///
|
||||
/// This is a request-response operation. The transport will match the
|
||||
/// response by request ID.
|
||||
async fn send_request(&mut self, request: JsonRpcRequest) -> Result<JsonRpcResult, TransportError>;
|
||||
|
||||
/// Send a JSON-RPC notification (fire-and-forget).
|
||||
///
|
||||
/// Notifications do not expect a response.
|
||||
async fn send_notification(&mut self, notification: JsonRpcNotification) -> Result<(), TransportError>;
|
||||
|
||||
/// Get a stream of notifications from the agent.
|
||||
///
|
||||
/// This returns a stream that yields notifications as they arrive.
|
||||
fn notification_stream(&mut self) -> NotificationStream;
|
||||
|
||||
/// Close the transport.
|
||||
///
|
||||
/// This gracefully shuts down the transport, cleaning up resources.
|
||||
async fn close(&mut self) -> Result<(), TransportError>;
|
||||
|
||||
/// Check if the transport is connected.
|
||||
fn is_connected(&self) -> bool;
|
||||
|
||||
/// Get the current transport state.
|
||||
fn state(&self) -> TransportState;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_request_new() {
|
||||
let req = JsonRpcRequest::new(1, "test_method", None);
|
||||
assert_eq!(req.jsonrpc, "2.0");
|
||||
assert_eq!(req.method, "test_method");
|
||||
assert_eq!(req.id, serde_json::Value::Number(1.into()));
|
||||
assert!(req.params.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_request_with_params() {
|
||||
let params = serde_json::json!({"key": "value"});
|
||||
let req = JsonRpcRequest::new(2, "test_method", Some(params.clone()));
|
||||
assert_eq!(req.params, Some(params));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_request_serialize() {
|
||||
let req = JsonRpcRequest::new(1, "test_method", None);
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_notification() {
|
||||
let notif = JsonRpcNotification::new("test_notification", None);
|
||||
assert_eq!(notif.jsonrpc, "2.0");
|
||||
assert_eq!(notif.method, "test_notification");
|
||||
assert!(notif.params.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_error() {
|
||||
let error = JsonRpcError {
|
||||
code: -32600,
|
||||
message: "Invalid Request".to_string(),
|
||||
data: None,
|
||||
};
|
||||
assert_eq!(error.code, -32600);
|
||||
assert_eq!(error.message, "Invalid Request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_result_success() {
|
||||
let response = JsonRpcResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Number(1.into()),
|
||||
result: serde_json::json!({"status": "ok"}),
|
||||
};
|
||||
let result = JsonRpcResult::Success(response);
|
||||
|
||||
assert!(result.is_success());
|
||||
assert!(!result.is_error());
|
||||
assert!(result.result().is_some());
|
||||
assert!(result.error().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_result_error() {
|
||||
let error_response = JsonRpcErrorResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Number(1.into()),
|
||||
error: JsonRpcError {
|
||||
code: -32600,
|
||||
message: "Invalid Request".to_string(),
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
let result = JsonRpcResult::Error(error_response);
|
||||
|
||||
assert!(!result.is_success());
|
||||
assert!(result.is_error());
|
||||
assert!(result.result().is_none());
|
||||
assert!(result.error().is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Dirigent Core Server
|
||||
//!
|
||||
//! Main server application that orchestrates ACP agents
|
||||
|
||||
use axum::{response::Json, routing::get, Router};
|
||||
use dirigent_acp_api::create_api_router;
|
||||
use dirigent_core::CoreConfig;
|
||||
use std::net::SocketAddr;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber;
|
||||
|
||||
const DEFAULT_PORT: u16 = 3000;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let _config = CoreConfig::default();
|
||||
|
||||
tracing::info!("Starting Dirigent Core server on port {}", DEFAULT_PORT);
|
||||
|
||||
// Create the main application router
|
||||
let app = Router::new()
|
||||
.route("/", get(root_handler))
|
||||
.route("/info", get(info_handler))
|
||||
.nest("/api", create_api_router())
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
// Start the server
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT));
|
||||
tracing::info!("Listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
async fn root_handler() -> &'static str {
|
||||
"Dirigent Core - ACP Agent Orchestrator\n\nEndpoints:\n GET /info - Server info\n GET /api - ACP API\n GET /api/health - Health check"
|
||||
}
|
||||
|
||||
async fn info_handler() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"name": "dirigent_core",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"description": "Orchestrates agentic clients with ACP",
|
||||
}))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,561 @@
|
||||
//! ACP Acceptor implementation
|
||||
//!
|
||||
//! This module provides the `AcpAcceptor` struct which serves as the entry point
|
||||
//! for incoming ACP connections. It tracks pending sessions from the ACP Server
|
||||
//! and routes them to appropriate connectors.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The AcpAcceptor is a lightweight coordinator that:
|
||||
//! - Maintains a list of pending incoming sessions
|
||||
//! - Routes sessions to target connectors
|
||||
//! - Optionally auto-routes new sessions to a default connector
|
||||
//!
|
||||
//! It does NOT:
|
||||
//! - Process messages directly (delegates to connectors)
|
||||
//! - Maintain direct connection to ACP clients (ACP Server does this)
|
||||
//! - Store session history (connectors and archivist handle this)
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use dirigent_core::connectors::acceptor::AcpAcceptor;
|
||||
//!
|
||||
//! // Create acceptor with auto-routing disabled
|
||||
//! let acceptor = AcpAcceptor::new("acp-acceptor-1".to_string(), "ACP Incoming".to_string());
|
||||
//!
|
||||
//! // Or with auto-routing to a default connector
|
||||
//! let acceptor = AcpAcceptor::builder("acp-acceptor-1".to_string(), "ACP Incoming".to_string())
|
||||
//! .with_default_connector("my-connector-id".to_string())
|
||||
//! .with_auto_routing(true)
|
||||
//! .build();
|
||||
//! ```
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{Acceptor, AcceptorError, AcceptorId, IncomingSession, SessionRouting};
|
||||
|
||||
/// Configuration for AcpAcceptor
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcpAcceptorConfig {
|
||||
/// Unique identifier for this acceptor
|
||||
pub id: AcceptorId,
|
||||
|
||||
/// Human-readable title
|
||||
pub title: String,
|
||||
|
||||
/// Default connector ID for auto-routing
|
||||
pub default_connector_id: Option<String>,
|
||||
|
||||
/// Whether to automatically route new sessions
|
||||
pub auto_routing: bool,
|
||||
}
|
||||
|
||||
impl AcpAcceptorConfig {
|
||||
/// Create a new config with defaults
|
||||
pub fn new(id: AcceptorId, title: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
default_connector_id: None,
|
||||
auto_routing: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for AcpAcceptor
|
||||
pub struct AcpAcceptorBuilder {
|
||||
config: AcpAcceptorConfig,
|
||||
}
|
||||
|
||||
impl AcpAcceptorBuilder {
|
||||
/// Create a new builder
|
||||
pub fn new(id: AcceptorId, title: String) -> Self {
|
||||
Self {
|
||||
config: AcpAcceptorConfig::new(id, title),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default connector for auto-routing
|
||||
pub fn with_default_connector(mut self, connector_id: String) -> Self {
|
||||
self.config.default_connector_id = Some(connector_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable auto-routing
|
||||
pub fn with_auto_routing(mut self, enabled: bool) -> Self {
|
||||
self.config.auto_routing = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the AcpAcceptor
|
||||
pub fn build(self) -> AcpAcceptor {
|
||||
AcpAcceptor::from_config(self.config)
|
||||
}
|
||||
}
|
||||
|
||||
/// ACP Acceptor for handling incoming ACP connections
|
||||
///
|
||||
/// This struct manages incoming sessions from the ACP Server and routes them
|
||||
/// to appropriate connectors for processing.
|
||||
pub struct AcpAcceptor {
|
||||
/// Unique identifier for this acceptor
|
||||
id: AcceptorId,
|
||||
|
||||
/// Human-readable title
|
||||
title: String,
|
||||
|
||||
/// Pending sessions awaiting routing
|
||||
///
|
||||
/// Key: session_id, Value: IncomingSession
|
||||
pending_sessions: Arc<RwLock<HashMap<String, IncomingSession>>>,
|
||||
|
||||
/// Session routings (for sessions that have been routed)
|
||||
///
|
||||
/// Key: session_id, Value: SessionRouting
|
||||
session_routings: Arc<RwLock<HashMap<String, SessionRouting>>>,
|
||||
|
||||
/// Default connector ID for auto-routing and accept operations
|
||||
default_connector_id: Arc<RwLock<Option<String>>>,
|
||||
|
||||
/// Whether auto-routing is enabled
|
||||
auto_routing: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl AcpAcceptor {
|
||||
/// Create a new AcpAcceptor with basic settings
|
||||
pub fn new(id: AcceptorId, title: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
pending_sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
session_routings: Arc::new(RwLock::new(HashMap::new())),
|
||||
default_connector_id: Arc::new(RwLock::new(None)),
|
||||
auto_routing: Arc::new(RwLock::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a builder for more configuration options
|
||||
pub fn builder(id: AcceptorId, title: String) -> AcpAcceptorBuilder {
|
||||
AcpAcceptorBuilder::new(id, title)
|
||||
}
|
||||
|
||||
/// Create from config
|
||||
fn from_config(config: AcpAcceptorConfig) -> Self {
|
||||
Self {
|
||||
id: config.id,
|
||||
title: config.title,
|
||||
pending_sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
session_routings: Arc::new(RwLock::new(HashMap::new())),
|
||||
default_connector_id: Arc::new(RwLock::new(config.default_connector_id)),
|
||||
auto_routing: Arc::new(RwLock::new(config.auto_routing)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an incoming session to the pending list
|
||||
///
|
||||
/// This is called by the ACP Server when a new session is created.
|
||||
/// If auto-routing is enabled and a default connector is configured,
|
||||
/// the session will be automatically routed.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(Some(SessionRouting))` if auto-routed
|
||||
/// - `Ok(None)` if added to pending list
|
||||
pub async fn add_incoming_session(
|
||||
&self,
|
||||
session: IncomingSession,
|
||||
) -> Result<Option<SessionRouting>, AcceptorError> {
|
||||
let session_id = session.session_id.clone();
|
||||
|
||||
// Check if auto-routing should happen
|
||||
let auto_routing = *self.auto_routing.read().await;
|
||||
let default_connector = self.default_connector_id.read().await.clone();
|
||||
|
||||
if auto_routing {
|
||||
if let Some(connector_id) = default_connector {
|
||||
info!(
|
||||
session_id = %session_id,
|
||||
connector_id = %connector_id,
|
||||
"Auto-routing incoming session"
|
||||
);
|
||||
|
||||
// Create routing directly (skip pending)
|
||||
let routing = SessionRouting::new(session_id.clone(), connector_id);
|
||||
let mut routings = self.session_routings.write().await;
|
||||
routings.insert(session_id, routing.clone());
|
||||
|
||||
return Ok(Some(routing));
|
||||
} else {
|
||||
warn!(
|
||||
session_id = %session_id,
|
||||
"Auto-routing enabled but no default connector configured"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to pending sessions
|
||||
info!(
|
||||
session_id = %session_id,
|
||||
client_id = %session.client_id,
|
||||
"Added incoming session to pending list"
|
||||
);
|
||||
|
||||
let mut pending = self.pending_sessions.write().await;
|
||||
pending.insert(session_id, session);
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Remove a session from tracking entirely
|
||||
///
|
||||
/// This is called when a session is closed or the client disconnects.
|
||||
pub async fn remove_session(&self, session_id: &str) {
|
||||
let mut pending = self.pending_sessions.write().await;
|
||||
pending.remove(session_id);
|
||||
|
||||
let mut routings = self.session_routings.write().await;
|
||||
routings.remove(session_id);
|
||||
|
||||
debug!(session_id = %session_id, "Removed session from acceptor tracking");
|
||||
}
|
||||
|
||||
/// Set the default connector ID
|
||||
pub async fn set_default_connector(&self, connector_id: Option<String>) {
|
||||
let mut default = self.default_connector_id.write().await;
|
||||
*default = connector_id;
|
||||
}
|
||||
|
||||
/// Enable or disable auto-routing
|
||||
pub async fn set_auto_routing(&self, enabled: bool) {
|
||||
let mut auto = self.auto_routing.write().await;
|
||||
*auto = enabled;
|
||||
}
|
||||
|
||||
/// Get the count of pending sessions
|
||||
pub async fn pending_count(&self) -> usize {
|
||||
self.pending_sessions.read().await.len()
|
||||
}
|
||||
|
||||
/// Get the count of routed sessions
|
||||
pub async fn routed_count(&self) -> usize {
|
||||
self.session_routings.read().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Acceptor for AcpAcceptor {
|
||||
fn id(&self) -> &AcceptorId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
async fn incoming_sessions(&self) -> Vec<IncomingSession> {
|
||||
let pending = self.pending_sessions.read().await;
|
||||
let mut sessions: Vec<IncomingSession> = pending.values().cloned().collect();
|
||||
|
||||
// Sort by created_at (newest first)
|
||||
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
sessions
|
||||
}
|
||||
|
||||
async fn accept_session(&self, session_id: &str) -> Result<SessionRouting, AcceptorError> {
|
||||
// Get the default connector
|
||||
let default_connector = self.default_connector_id.read().await.clone();
|
||||
|
||||
match default_connector {
|
||||
Some(connector_id) => self.route_to_connector(session_id, &connector_id).await,
|
||||
None => Err(AcceptorError::Internal(
|
||||
"No default connector configured for accept operation".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_session(&self, session_id: &str) -> Result<(), AcceptorError> {
|
||||
let mut pending = self.pending_sessions.write().await;
|
||||
|
||||
if pending.remove(session_id).is_some() {
|
||||
info!(session_id = %session_id, "Rejected incoming session");
|
||||
Ok(())
|
||||
} else {
|
||||
// Check if it was already routed
|
||||
let routings = self.session_routings.read().await;
|
||||
if routings.contains_key(session_id) {
|
||||
Err(AcceptorError::AlreadyRouted(session_id.to_string()))
|
||||
} else {
|
||||
Err(AcceptorError::SessionNotFound(session_id.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn route_to_connector(
|
||||
&self,
|
||||
session_id: &str,
|
||||
connector_id: &str,
|
||||
) -> Result<SessionRouting, AcceptorError> {
|
||||
// Remove from pending
|
||||
let mut pending = self.pending_sessions.write().await;
|
||||
|
||||
if pending.remove(session_id).is_none() {
|
||||
// Check if already routed
|
||||
let routings = self.session_routings.read().await;
|
||||
if routings.contains_key(session_id) {
|
||||
return Err(AcceptorError::AlreadyRouted(session_id.to_string()));
|
||||
}
|
||||
return Err(AcceptorError::SessionNotFound(session_id.to_string()));
|
||||
}
|
||||
|
||||
// Create and store routing
|
||||
let routing = SessionRouting::new(session_id.to_string(), connector_id.to_string());
|
||||
|
||||
let mut routings = self.session_routings.write().await;
|
||||
routings.insert(session_id.to_string(), routing.clone());
|
||||
|
||||
info!(
|
||||
session_id = %session_id,
|
||||
connector_id = %connector_id,
|
||||
"Routed session to connector"
|
||||
);
|
||||
|
||||
Ok(routing)
|
||||
}
|
||||
|
||||
async fn get_session_routing(&self, session_id: &str) -> Option<SessionRouting> {
|
||||
let routings = self.session_routings.read().await;
|
||||
routings.get(session_id).cloned()
|
||||
}
|
||||
|
||||
fn auto_routing_enabled(&self) -> bool {
|
||||
// Use try_read for non-blocking access in sync context
|
||||
self.auto_routing
|
||||
.try_read()
|
||||
.map(|guard| *guard)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn default_connector_id(&self) -> Option<&str> {
|
||||
// This is tricky because we can't return a reference to data behind RwLock
|
||||
// In practice, callers should use the async methods or get_session_routing
|
||||
// For now, return None - the async version should be preferred
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension methods for getting routing info asynchronously
|
||||
impl AcpAcceptor {
|
||||
/// Get the default connector ID asynchronously
|
||||
pub async fn default_connector_id_async(&self) -> Option<String> {
|
||||
self.default_connector_id.read().await.clone()
|
||||
}
|
||||
|
||||
/// Check if auto-routing is enabled asynchronously
|
||||
pub async fn auto_routing_enabled_async(&self) -> bool {
|
||||
*self.auto_routing.read().await
|
||||
}
|
||||
|
||||
/// Transfer a session from one connector to another
|
||||
///
|
||||
/// This updates the routing to point to a new connector.
|
||||
/// The session must already be routed.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The session to transfer
|
||||
/// * `to_connector_id` - The new target connector
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The new routing information
|
||||
pub async fn transfer_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
to_connector_id: &str,
|
||||
) -> Result<SessionRouting, AcceptorError> {
|
||||
let mut routings = self.session_routings.write().await;
|
||||
|
||||
// Verify session exists and is routed
|
||||
if !routings.contains_key(session_id) {
|
||||
// Check if it's pending
|
||||
let pending = self.pending_sessions.read().await;
|
||||
if pending.contains_key(session_id) {
|
||||
return Err(AcceptorError::Internal(
|
||||
"Session is pending, not routed. Use route_to_connector instead.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(AcceptorError::SessionNotFound(session_id.to_string()));
|
||||
}
|
||||
|
||||
// Update the routing
|
||||
let routing = SessionRouting::new(session_id.to_string(), to_connector_id.to_string());
|
||||
routings.insert(session_id.to_string(), routing.clone());
|
||||
|
||||
info!(
|
||||
session_id = %session_id,
|
||||
to_connector_id = %to_connector_id,
|
||||
"Transferred session to new connector"
|
||||
);
|
||||
|
||||
Ok(routing)
|
||||
}
|
||||
|
||||
/// Get all session routings
|
||||
pub async fn all_routings(&self) -> HashMap<String, SessionRouting> {
|
||||
self.session_routings.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_acp_acceptor_creation() {
|
||||
let acceptor = AcpAcceptor::new("acceptor-1".to_string(), "Test Acceptor".to_string());
|
||||
|
||||
assert_eq!(acceptor.id(), "acceptor-1");
|
||||
assert_eq!(acceptor.title(), "Test Acceptor");
|
||||
assert_eq!(acceptor.pending_count().await, 0);
|
||||
assert_eq!(acceptor.routed_count().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_incoming_session() {
|
||||
let acceptor = AcpAcceptor::new("acceptor-1".to_string(), "Test Acceptor".to_string());
|
||||
|
||||
let session =
|
||||
IncomingSession::new("session-1".to_string(), "client-1".to_string());
|
||||
|
||||
let result = acceptor.add_incoming_session(session).await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none()); // No auto-routing
|
||||
|
||||
assert_eq!(acceptor.pending_count().await, 1);
|
||||
|
||||
let pending = acceptor.incoming_sessions().await;
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].session_id, "session-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auto_routing() {
|
||||
let acceptor = AcpAcceptor::builder("acceptor-1".to_string(), "Test Acceptor".to_string())
|
||||
.with_default_connector("default-connector".to_string())
|
||||
.with_auto_routing(true)
|
||||
.build();
|
||||
|
||||
let session =
|
||||
IncomingSession::new("session-1".to_string(), "client-1".to_string());
|
||||
|
||||
let result = acceptor.add_incoming_session(session).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let routing = result.unwrap();
|
||||
assert!(routing.is_some());
|
||||
let routing = routing.unwrap();
|
||||
assert_eq!(routing.session_id, "session-1");
|
||||
assert_eq!(routing.connector_id, "default-connector");
|
||||
|
||||
// Session should not be in pending
|
||||
assert_eq!(acceptor.pending_count().await, 0);
|
||||
// Session should be in routings
|
||||
assert_eq!(acceptor.routed_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_route_to_connector() {
|
||||
let acceptor = AcpAcceptor::new("acceptor-1".to_string(), "Test Acceptor".to_string());
|
||||
|
||||
let session =
|
||||
IncomingSession::new("session-1".to_string(), "client-1".to_string());
|
||||
acceptor.add_incoming_session(session).await.unwrap();
|
||||
|
||||
let routing = acceptor
|
||||
.route_to_connector("session-1", "my-connector")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(routing.session_id, "session-1");
|
||||
assert_eq!(routing.connector_id, "my-connector");
|
||||
|
||||
// Session should be removed from pending
|
||||
assert_eq!(acceptor.pending_count().await, 0);
|
||||
// Session should be in routings
|
||||
assert_eq!(acceptor.routed_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reject_session() {
|
||||
let acceptor = AcpAcceptor::new("acceptor-1".to_string(), "Test Acceptor".to_string());
|
||||
|
||||
let session =
|
||||
IncomingSession::new("session-1".to_string(), "client-1".to_string());
|
||||
acceptor.add_incoming_session(session).await.unwrap();
|
||||
|
||||
assert_eq!(acceptor.pending_count().await, 1);
|
||||
|
||||
acceptor.reject_session("session-1").await.unwrap();
|
||||
|
||||
assert_eq!(acceptor.pending_count().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transfer_session() {
|
||||
let acceptor = AcpAcceptor::new("acceptor-1".to_string(), "Test Acceptor".to_string());
|
||||
|
||||
// Add and route a session
|
||||
let session =
|
||||
IncomingSession::new("session-1".to_string(), "client-1".to_string());
|
||||
acceptor.add_incoming_session(session).await.unwrap();
|
||||
acceptor.route_to_connector("session-1", "connector-1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Transfer to new connector
|
||||
let new_routing = acceptor
|
||||
.transfer_session("session-1", "connector-2")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(new_routing.connector_id, "connector-2");
|
||||
|
||||
// Verify the routing was updated
|
||||
let routing = acceptor.get_session_routing("session-1").await.unwrap();
|
||||
assert_eq!(routing.connector_id, "connector-2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_cases() {
|
||||
let acceptor = AcpAcceptor::new("acceptor-1".to_string(), "Test Acceptor".to_string());
|
||||
|
||||
// Route non-existent session
|
||||
let result = acceptor.route_to_connector("non-existent", "connector").await;
|
||||
assert!(matches!(result, Err(AcceptorError::SessionNotFound(_))));
|
||||
|
||||
// Reject non-existent session
|
||||
let result = acceptor.reject_session("non-existent").await;
|
||||
assert!(matches!(result, Err(AcceptorError::SessionNotFound(_))));
|
||||
|
||||
// Add session, route it, then try to route again
|
||||
let session =
|
||||
IncomingSession::new("session-1".to_string(), "client-1".to_string());
|
||||
acceptor.add_incoming_session(session).await.unwrap();
|
||||
acceptor.route_to_connector("session-1", "connector-1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = acceptor.route_to_connector("session-1", "connector-2").await;
|
||||
assert!(matches!(result, Err(AcceptorError::AlreadyRouted(_))));
|
||||
|
||||
// Reject already routed session
|
||||
let result = acceptor.reject_session("session-1").await;
|
||||
assert!(matches!(result, Err(AcceptorError::AlreadyRouted(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Acceptor abstraction layer for incoming connections
|
||||
//!
|
||||
//! This module provides the `Acceptor` trait that represents an entry point for incoming
|
||||
//! ACP connections. Unlike Connectors which initiate outbound connections to agents,
|
||||
//! Acceptors accept inbound connections from external clients.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! An Acceptor:
|
||||
//! - Accepts incoming connections from ACP clients
|
||||
//! - Tracks pending sessions awaiting routing decisions
|
||||
//! - Routes sessions to target Connectors for processing
|
||||
//! - Does NOT process messages itself (delegates to Connectors)
|
||||
//!
|
||||
//! # Session Lifecycle
|
||||
//!
|
||||
//! 1. Client connects to ACP Server and creates a session
|
||||
//! 2. Session appears as "pending" in the Acceptor
|
||||
//! 3. User (or auto-routing) assigns session to a Connector
|
||||
//! 4. Once routed, the Connector handles all message processing
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use dirigent_core::connectors::acceptor::{Acceptor, IncomingSession};
|
||||
//!
|
||||
//! async fn example(acceptor: impl Acceptor) {
|
||||
//! // Get pending sessions
|
||||
//! let pending = acceptor.incoming_sessions().await;
|
||||
//!
|
||||
//! for session in pending {
|
||||
//! // Route to a connector
|
||||
//! acceptor.route_to_connector(&session.session_id, "my-connector-id").await?;
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
//! Note: This module requires the "server" feature flag.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod acp_acceptor;
|
||||
|
||||
// Re-export AcpAcceptor for convenience
|
||||
pub use acp_acceptor::AcpAcceptor;
|
||||
|
||||
/// Unique identifier for an acceptor instance
|
||||
pub type AcceptorId = String;
|
||||
|
||||
/// Information about an incoming session awaiting routing
|
||||
///
|
||||
/// When an ACP client connects and creates a session through the ACP Server,
|
||||
/// that session is initially "pending" - it exists but hasn't been assigned
|
||||
/// to a specific Connector for processing.
|
||||
///
|
||||
/// This struct contains the metadata needed to display pending sessions
|
||||
/// in the UI and make routing decisions.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct IncomingSession {
|
||||
/// Unique session identifier (from the ACP Server)
|
||||
pub session_id: String,
|
||||
|
||||
/// Client identifier (who created this session)
|
||||
pub client_id: String,
|
||||
|
||||
/// Optional client metadata (capabilities, name, version, etc.)
|
||||
///
|
||||
/// This is typically the client info provided during the ACP `initialize`
|
||||
/// handshake. It can include:
|
||||
/// - client_name: Name of the client application
|
||||
/// - client_version: Version string
|
||||
/// - capabilities: Supported features
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_info: Option<serde_json::Value>,
|
||||
|
||||
/// When this session was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
||||
/// Optional title for display
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl IncomingSession {
|
||||
/// Create a new IncomingSession
|
||||
pub fn new(session_id: String, client_id: String) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
client_id,
|
||||
client_info: None,
|
||||
created_at: Utc::now(),
|
||||
title: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with client info
|
||||
pub fn with_client_info(mut self, info: serde_json::Value) -> Self {
|
||||
self.client_info = Some(info);
|
||||
self
|
||||
}
|
||||
|
||||
/// Create with title
|
||||
pub fn with_title(mut self, title: String) -> Self {
|
||||
self.title = Some(title);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of successfully routing a session
|
||||
///
|
||||
/// When a session is routed to a connector, this struct provides
|
||||
/// information about the mapping that was created.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SessionRouting {
|
||||
/// The incoming session ID (from client)
|
||||
pub session_id: String,
|
||||
|
||||
/// The connector this session was routed to
|
||||
pub connector_id: String,
|
||||
|
||||
/// When the routing was established
|
||||
pub routed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SessionRouting {
|
||||
/// Create a new SessionRouting
|
||||
pub fn new(session_id: String, connector_id: String) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
connector_id,
|
||||
routed_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for acceptor operations
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum AcceptorError {
|
||||
/// Session not found in pending list
|
||||
#[error("Session not found: {0}")]
|
||||
SessionNotFound(String),
|
||||
|
||||
/// Connector not found or not available
|
||||
#[error("Connector not available: {0}")]
|
||||
ConnectorNotAvailable(String),
|
||||
|
||||
/// Session already routed to a connector
|
||||
#[error("Session already routed: {0}")]
|
||||
AlreadyRouted(String),
|
||||
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Trait for incoming connection handlers (Acceptors)
|
||||
///
|
||||
/// An Acceptor represents an entry point for incoming ACP connections.
|
||||
/// It tracks pending sessions and routes them to appropriate Connectors.
|
||||
///
|
||||
/// # Object Safety
|
||||
///
|
||||
/// This trait is designed to be object-safe, allowing for `dyn Acceptor` usage.
|
||||
/// All async methods use `async_trait` for compatibility.
|
||||
#[async_trait]
|
||||
pub trait Acceptor: Send + Sync {
|
||||
/// Get the unique identifier for this acceptor
|
||||
fn id(&self) -> &AcceptorId;
|
||||
|
||||
/// Get the human-readable title for this acceptor
|
||||
fn title(&self) -> &str;
|
||||
|
||||
/// Get all pending incoming sessions
|
||||
///
|
||||
/// Returns sessions that have been created but not yet routed to a connector.
|
||||
/// Once a session is routed, it no longer appears in this list.
|
||||
async fn incoming_sessions(&self) -> Vec<IncomingSession>;
|
||||
|
||||
/// Accept a session and route it to a connector
|
||||
///
|
||||
/// This is a convenience method that routes the session to the default
|
||||
/// connector (if one is configured) or marks it as accepted for manual routing.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The ID of the pending session to accept
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The routing information if successful, or an error if the session
|
||||
/// was not found or routing failed.
|
||||
async fn accept_session(&self, session_id: &str) -> Result<SessionRouting, AcceptorError>;
|
||||
|
||||
/// Reject a session and remove it from the pending list
|
||||
///
|
||||
/// This notifies the client that their session was rejected and
|
||||
/// removes it from the pending sessions list.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The ID of the pending session to reject
|
||||
async fn reject_session(&self, session_id: &str) -> Result<(), AcceptorError>;
|
||||
|
||||
/// Route a session to a specific connector
|
||||
///
|
||||
/// Once routed, the connector will handle all message processing for this
|
||||
/// session. The session is removed from the pending list.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `session_id` - The ID of the pending session
|
||||
/// * `connector_id` - The ID of the connector to route to
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The routing information if successful, or an error if:
|
||||
/// - The session was not found
|
||||
/// - The connector was not found or not available
|
||||
/// - The session was already routed
|
||||
async fn route_to_connector(
|
||||
&self,
|
||||
session_id: &str,
|
||||
connector_id: &str,
|
||||
) -> Result<SessionRouting, AcceptorError>;
|
||||
|
||||
/// Get the current routing for a session (if routed)
|
||||
///
|
||||
/// Returns None if the session is still pending, or the routing
|
||||
/// information if it has been assigned to a connector.
|
||||
async fn get_session_routing(&self, session_id: &str) -> Option<SessionRouting>;
|
||||
|
||||
/// Check if auto-routing is enabled
|
||||
///
|
||||
/// When auto-routing is enabled, new incoming sessions are automatically
|
||||
/// routed to the default connector without manual intervention.
|
||||
fn auto_routing_enabled(&self) -> bool;
|
||||
|
||||
/// Get the default connector ID for auto-routing
|
||||
///
|
||||
/// Returns the connector ID that new sessions should be automatically
|
||||
/// routed to, if auto-routing is enabled.
|
||||
fn default_connector_id(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
/// Summary information about an acceptor for UI display
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AcceptorSummary {
|
||||
/// Unique acceptor identifier
|
||||
pub id: AcceptorId,
|
||||
|
||||
/// Human-readable title for display
|
||||
pub title: String,
|
||||
|
||||
/// Number of pending incoming sessions
|
||||
pub pending_sessions_count: usize,
|
||||
|
||||
/// Whether auto-routing is enabled
|
||||
pub auto_routing_enabled: bool,
|
||||
|
||||
/// Default connector ID (if configured)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_connector_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Handle to an acceptor instance
|
||||
///
|
||||
/// This provides a cloneable reference to an acceptor that can be shared
|
||||
/// across async tasks.
|
||||
#[derive(Clone)]
|
||||
pub struct AcceptorHandle {
|
||||
/// The underlying acceptor implementation
|
||||
acceptor: Arc<dyn Acceptor>,
|
||||
}
|
||||
|
||||
impl AcceptorHandle {
|
||||
/// Create a new AcceptorHandle wrapping an acceptor implementation
|
||||
pub fn new(acceptor: impl Acceptor + 'static) -> Self {
|
||||
Self {
|
||||
acceptor: Arc::new(acceptor),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from an Arc-wrapped acceptor
|
||||
pub fn from_arc(acceptor: Arc<dyn Acceptor>) -> Self {
|
||||
Self { acceptor }
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying acceptor
|
||||
pub fn as_ref(&self) -> &dyn Acceptor {
|
||||
self.acceptor.as_ref()
|
||||
}
|
||||
|
||||
/// Get the acceptor ID
|
||||
pub fn id(&self) -> &AcceptorId {
|
||||
self.acceptor.id()
|
||||
}
|
||||
|
||||
/// Get the acceptor title
|
||||
pub fn title(&self) -> &str {
|
||||
self.acceptor.title()
|
||||
}
|
||||
|
||||
/// Get pending incoming sessions
|
||||
pub async fn incoming_sessions(&self) -> Vec<IncomingSession> {
|
||||
self.acceptor.incoming_sessions().await
|
||||
}
|
||||
|
||||
/// Accept a session
|
||||
pub async fn accept_session(&self, session_id: &str) -> Result<SessionRouting, AcceptorError> {
|
||||
self.acceptor.accept_session(session_id).await
|
||||
}
|
||||
|
||||
/// Reject a session
|
||||
pub async fn reject_session(&self, session_id: &str) -> Result<(), AcceptorError> {
|
||||
self.acceptor.reject_session(session_id).await
|
||||
}
|
||||
|
||||
/// Route a session to a connector
|
||||
pub async fn route_to_connector(
|
||||
&self,
|
||||
session_id: &str,
|
||||
connector_id: &str,
|
||||
) -> Result<SessionRouting, AcceptorError> {
|
||||
self.acceptor.route_to_connector(session_id, connector_id).await
|
||||
}
|
||||
|
||||
/// Get session routing
|
||||
pub async fn get_session_routing(&self, session_id: &str) -> Option<SessionRouting> {
|
||||
self.acceptor.get_session_routing(session_id).await
|
||||
}
|
||||
|
||||
/// Check if auto-routing is enabled
|
||||
pub fn auto_routing_enabled(&self) -> bool {
|
||||
self.acceptor.auto_routing_enabled()
|
||||
}
|
||||
|
||||
/// Get default connector ID
|
||||
pub fn default_connector_id(&self) -> Option<&str> {
|
||||
self.acceptor.default_connector_id()
|
||||
}
|
||||
|
||||
/// Get a summary of this acceptor for UI display
|
||||
pub async fn summary(&self) -> AcceptorSummary {
|
||||
let pending = self.incoming_sessions().await;
|
||||
AcceptorSummary {
|
||||
id: self.id().clone(),
|
||||
title: self.title().to_string(),
|
||||
pending_sessions_count: pending.len(),
|
||||
auto_routing_enabled: self.auto_routing_enabled(),
|
||||
default_connector_id: self.default_connector_id().map(String::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_incoming_session_creation() {
|
||||
let session = IncomingSession::new("session-1".to_string(), "client-1".to_string());
|
||||
|
||||
assert_eq!(session.session_id, "session-1");
|
||||
assert_eq!(session.client_id, "client-1");
|
||||
assert!(session.client_info.is_none());
|
||||
assert!(session.title.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_incoming_session_with_info() {
|
||||
let session = IncomingSession::new("session-1".to_string(), "client-1".to_string())
|
||||
.with_client_info(serde_json::json!({
|
||||
"client_name": "test-client",
|
||||
"version": "1.0.0"
|
||||
}))
|
||||
.with_title("Test Session".to_string());
|
||||
|
||||
assert!(session.client_info.is_some());
|
||||
assert_eq!(session.title, Some("Test Session".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_routing() {
|
||||
let routing = SessionRouting::new("session-1".to_string(), "connector-1".to_string());
|
||||
|
||||
assert_eq!(routing.session_id, "session-1");
|
||||
assert_eq!(routing.connector_id, "connector-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acceptor_error_display() {
|
||||
let error = AcceptorError::SessionNotFound("session-1".to_string());
|
||||
assert!(error.to_string().contains("session-1"));
|
||||
|
||||
let error = AcceptorError::ConnectorNotAvailable("connector-1".to_string());
|
||||
assert!(error.to_string().contains("connector-1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
//! ACP Connector configuration
|
||||
//!
|
||||
//! This module provides configuration types for ACP connectors, including
|
||||
//! transport selection (Stdio vs HTTP), feature support, and validation logic.
|
||||
|
||||
use dirigent_protocol::SessionOwnership;
|
||||
use dirigent_tools::EmbeddingConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Well-known agent types for ACP connectors.
|
||||
///
|
||||
/// This identifies the specific agent implementation behind an ACP connector,
|
||||
/// enabling automatic mode/model mapping during session transfers.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectorAgentType {
|
||||
/// Custom or unknown agent (no automatic mode/model mapping)
|
||||
#[default]
|
||||
Custom,
|
||||
/// Anthropic Claude Code / Claude API
|
||||
Claude,
|
||||
/// OpenAI Codex / ChatGPT Code Interpreter
|
||||
Codex,
|
||||
/// Google Gemini Code Assist
|
||||
Gemini,
|
||||
}
|
||||
|
||||
impl ConnectorAgentType {
|
||||
/// Parse from string magic word (case-insensitive).
|
||||
///
|
||||
/// Supports aliases like "openai" for Codex and "google" for Gemini.
|
||||
pub fn from_magic_word(word: &str) -> Option<Self> {
|
||||
match word.to_lowercase().as_str() {
|
||||
"claude" => Some(Self::Claude),
|
||||
"codex" | "openai" => Some(Self::Codex),
|
||||
"gemini" | "google" => Some(Self::Gemini),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Well-known ACP feature identifiers
|
||||
///
|
||||
/// These constants define standard features that ACP agents may support.
|
||||
/// Use these when configuring `supported_features` in `AcpConfig`.
|
||||
pub mod features {
|
||||
/// Agent supports `session/load` to resume existing sessions
|
||||
///
|
||||
/// Without this feature, archived sessions cannot be loaded back into the agent.
|
||||
/// The UI should disable "load from archive" functionality.
|
||||
pub const SESSION_RESUME: &str = "session_resume";
|
||||
|
||||
/// Agent supports `session/list` to enumerate available sessions
|
||||
///
|
||||
/// Without this feature, the connector cannot list sessions from the agent.
|
||||
pub const SESSION_LIST: &str = "session_list";
|
||||
|
||||
/// Agent provides message history when loading sessions
|
||||
///
|
||||
/// Without this feature, loaded sessions start empty (no replay).
|
||||
pub const MESSAGE_HISTORY: &str = "message_history";
|
||||
|
||||
/// Agent supports cancellation via `session/cancel`
|
||||
pub const CANCELLATION: &str = "cancellation";
|
||||
|
||||
/// Agent supports model selection
|
||||
pub const MODEL_SELECTION: &str = "model_selection";
|
||||
|
||||
/// Agent supports mode selection (e.g., plan mode, bypass permissions)
|
||||
pub const MODE_SELECTION: &str = "mode_selection";
|
||||
}
|
||||
|
||||
/// Transport mechanism for ACP communication
|
||||
///
|
||||
/// ACP supports multiple transport mechanisms for different deployment scenarios:
|
||||
/// - Stdio: For locally spawned agent processes
|
||||
/// - Http: For remote agents accessible via HTTP/SSE
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum TransportKind {
|
||||
/// Stdio transport (spawn process, communicate via stdin/stdout)
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `command` - Executable path or name (resolved via PATH)
|
||||
/// * `args` - Command-line arguments to pass to the process
|
||||
/// * `cwd` - Optional working directory for the process
|
||||
/// * `env` - Optional environment variables
|
||||
Stdio {
|
||||
/// Command to execute (e.g., "dirigate", "/path/to/agent")
|
||||
command: String,
|
||||
/// Command-line arguments
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
/// Working directory (defaults to current directory)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<PathBuf>,
|
||||
/// Environment variables
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
env: Vec<(String, String)>,
|
||||
},
|
||||
|
||||
/// HTTP transport (connect to remote agent via HTTP/SSE)
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `base_url` - Base URL of the ACP agent HTTP endpoint
|
||||
/// * `timeout_ms` - Optional request timeout in milliseconds
|
||||
Http {
|
||||
/// Base URL (e.g., "http://localhost:3000", "https://agent.example.com")
|
||||
base_url: String,
|
||||
/// Request timeout in milliseconds (default: 30000)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
timeout_ms: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Configuration for ACP connector
|
||||
///
|
||||
/// Contains all the information needed to create and connect to an ACP agent,
|
||||
/// including transport configuration and operational parameters.
|
||||
///
|
||||
/// Note: Title and supported_features are now orchestration-level fields
|
||||
/// stored in ConnectorConfig, not here.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AcpConfig {
|
||||
/// Transport configuration
|
||||
///
|
||||
/// Determines how to connect to the ACP agent (stdio process or HTTP endpoint).
|
||||
pub transport: TransportKind,
|
||||
|
||||
/// Protocol version to use (default: 1)
|
||||
///
|
||||
/// Specifies which version of the ACP protocol to negotiate during initialization.
|
||||
#[serde(default = "default_protocol_version")]
|
||||
pub protocol_version: u32,
|
||||
|
||||
/// Current working directory for sessions (default: ".")
|
||||
///
|
||||
/// Passed to the agent when creating new sessions. Determines the file system
|
||||
/// context for tools and operations.
|
||||
#[serde(default = "default_cwd")]
|
||||
pub cwd: String,
|
||||
|
||||
/// Connection retry configuration
|
||||
///
|
||||
/// Controls how the connector behaves when connection attempts fail.
|
||||
#[serde(default)]
|
||||
pub retry: RetryConfig,
|
||||
|
||||
/// File embedding configuration
|
||||
///
|
||||
/// Controls how files are embedded in prompts (size limits, redaction, etc.)
|
||||
#[serde(default)]
|
||||
pub embedding: EmbeddingConfig,
|
||||
|
||||
/// Default ownership for capability negotiation (used during initialize)
|
||||
///
|
||||
/// This ownership model determines what capabilities are advertised to the agent
|
||||
/// during the initialize handshake. For UI-created connectors, this defaults to
|
||||
/// internal ownership (empty capabilities). For connectors created by ACP Acceptor
|
||||
/// (incoming external clients), this should be set to external_forwarded with the
|
||||
/// client's capabilities.
|
||||
#[serde(default)]
|
||||
pub default_ownership: SessionOwnership,
|
||||
|
||||
/// Override directory for ACP protocol logging (optional)
|
||||
///
|
||||
/// All JSON-RPC messages (stdin/stdout) are logged to JSONL files.
|
||||
/// Defaults to `data_dir/logs/acp/` when not set. Set this to override
|
||||
/// the default location.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub acp_log_dir: Option<PathBuf>,
|
||||
|
||||
/// Agent type for automatic mode/model mapping.
|
||||
///
|
||||
/// When set to a specific agent type (Claude, Codex, Gemini), the system
|
||||
/// will automatically translate Gateway mode/model identifiers to
|
||||
/// agent-specific identifiers during session transfers.
|
||||
#[serde(default)]
|
||||
pub agent_type: ConnectorAgentType,
|
||||
}
|
||||
|
||||
/// Retry configuration for connection failures
|
||||
///
|
||||
/// Controls reconnection behavior when the transport fails or disconnects.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RetryConfig {
|
||||
/// Maximum number of retry attempts (default: 5)
|
||||
///
|
||||
/// After this many failed attempts, the connector enters Error state
|
||||
/// and waits for manual Reconnect command.
|
||||
#[serde(default = "default_max_retries")]
|
||||
pub max_retries: usize,
|
||||
|
||||
/// Retry delays in milliseconds (default: [60000, 60000, 60000, 60000, 60000])
|
||||
///
|
||||
/// Delays between retry attempts. Defaults to 60 seconds to avoid reconnect
|
||||
/// spam when SSE connections drop. If retries exceed the length of this
|
||||
/// array, the last delay is reused.
|
||||
#[serde(default = "default_retry_delays")]
|
||||
pub retry_delays_ms: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: default_max_retries(),
|
||||
retry_delays_ms: default_retry_delays(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default value functions for serde
|
||||
|
||||
fn default_protocol_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_cwd() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
|
||||
fn default_max_retries() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_retry_delays() -> Vec<u64> {
|
||||
// First retry after 60 seconds to avoid spam when SSE disconnects
|
||||
// Subsequent retries use same 60-second delay
|
||||
vec![60000, 60000, 60000, 60000, 60000]
|
||||
}
|
||||
|
||||
impl AcpConfig {
|
||||
/// Set the maximum number of retry attempts
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `max` - Maximum retry attempts before entering Error state
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Self for method chaining
|
||||
pub fn with_retry_max_attempts(mut self, max: usize) -> Self {
|
||||
self.retry.max_retries = max;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the initial retry delay
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `delay` - Initial delay before first retry
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Self for method chaining
|
||||
pub fn with_retry_initial_delay(mut self, delay: std::time::Duration) -> Self {
|
||||
let delay_ms = delay.as_millis() as u64;
|
||||
if !self.retry.retry_delays_ms.is_empty() {
|
||||
self.retry.retry_delays_ms[0] = delay_ms;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the request timeout for HTTP transport
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `timeout` - Request timeout duration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Self for method chaining
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// This only applies to HTTP transport. For stdio transport, this is a no-op.
|
||||
pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
|
||||
if let TransportKind::Http { timeout_ms, .. } = &mut self.transport {
|
||||
*timeout_ms = Some(timeout.as_millis() as u64);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Validate configuration and return errors if invalid
|
||||
///
|
||||
/// Checks for common configuration mistakes like:
|
||||
/// - Empty command for stdio transport
|
||||
/// - Invalid URL for HTTP transport
|
||||
/// - Negative or zero timeouts
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Ok(())` if configuration is valid, `Err(String)` with error message otherwise.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Validate transport
|
||||
match &self.transport {
|
||||
TransportKind::Stdio { command, .. } => {
|
||||
if command.trim().is_empty() {
|
||||
return Err("Stdio transport requires non-empty command".to_string());
|
||||
}
|
||||
}
|
||||
TransportKind::Http { base_url, timeout_ms } => {
|
||||
if base_url.trim().is_empty() {
|
||||
return Err("HTTP transport requires non-empty base_url".to_string());
|
||||
}
|
||||
// Basic URL validation
|
||||
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
|
||||
return Err("HTTP base_url must start with http:// or https://".to_string());
|
||||
}
|
||||
if let Some(timeout) = timeout_ms {
|
||||
if *timeout == 0 {
|
||||
return Err("HTTP timeout must be greater than 0".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate protocol version
|
||||
if self.protocol_version == 0 {
|
||||
return Err("Protocol version must be >= 1".to_string());
|
||||
}
|
||||
|
||||
// Validate retry config
|
||||
if self.retry.max_retries == 0 {
|
||||
return Err("max_retries must be >= 1".to_string());
|
||||
}
|
||||
if self.retry.retry_delays_ms.is_empty() {
|
||||
return Err("retry_delays_ms cannot be empty".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// REMOVED: supports_feature() and with_features() methods
|
||||
// supported_features is now in ConnectorConfig, not AcpConfig
|
||||
|
||||
/// Create a default stdio configuration for testing
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `command` - Command to execute
|
||||
/// * `args` - Command-line arguments
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// AcpConfig with stdio transport and default values
|
||||
pub fn stdio(command: impl Into<String>, args: Vec<String>) -> Self {
|
||||
Self {
|
||||
transport: TransportKind::Stdio {
|
||||
command: command.into(),
|
||||
args,
|
||||
cwd: None,
|
||||
env: vec![],
|
||||
},
|
||||
protocol_version: 1,
|
||||
cwd: ".".to_string(),
|
||||
retry: RetryConfig::default(),
|
||||
embedding: EmbeddingConfig::default(),
|
||||
default_ownership: SessionOwnership::default(),
|
||||
acp_log_dir: None,
|
||||
agent_type: ConnectorAgentType::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a default HTTP configuration
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_url` - Base URL of the ACP agent
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// AcpConfig with HTTP transport and default values
|
||||
pub fn http(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
transport: TransportKind::Http {
|
||||
base_url: base_url.into(),
|
||||
timeout_ms: Some(30_000),
|
||||
},
|
||||
protocol_version: 1,
|
||||
cwd: ".".to_string(),
|
||||
retry: RetryConfig::default(),
|
||||
embedding: EmbeddingConfig::default(),
|
||||
default_ownership: SessionOwnership::default(),
|
||||
acp_log_dir: None,
|
||||
agent_type: ConnectorAgentType::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transport_kind_stdio_serialization() {
|
||||
let transport = TransportKind::Stdio {
|
||||
command: "test-agent".to_string(),
|
||||
args: vec!["--stdio".to_string()],
|
||||
cwd: None,
|
||||
env: vec![],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&transport).unwrap();
|
||||
let deserialized: TransportKind = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized, transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transport_kind_http_serialization() {
|
||||
let transport = TransportKind::Http {
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
timeout_ms: Some(60_000),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&transport).unwrap();
|
||||
let deserialized: TransportKind = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized, transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_defaults() {
|
||||
let config = AcpConfig::stdio("agent", vec![]);
|
||||
|
||||
assert_eq!(config.protocol_version, 1);
|
||||
assert_eq!(config.cwd, ".");
|
||||
assert_eq!(config.retry.max_retries, 5);
|
||||
assert_eq!(config.retry.retry_delays_ms.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_validation_stdio_empty_command() {
|
||||
let config = AcpConfig::stdio("", vec![]);
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("non-empty command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_validation_http_empty_url() {
|
||||
let config = AcpConfig::http("");
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("non-empty base_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_validation_http_invalid_url() {
|
||||
let config = AcpConfig::http("not-a-url");
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("http://"));
|
||||
}
|
||||
|
||||
// REMOVED: test_acp_config_validation_empty_title
|
||||
// Title is now in ConnectorConfig, not AcpConfig
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_validation_zero_protocol_version() {
|
||||
let mut config = AcpConfig::stdio("agent", vec![]);
|
||||
config.protocol_version = 0;
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Protocol version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_validation_valid() {
|
||||
let config = AcpConfig::stdio("agent", vec!["--stdio".to_string()]);
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_http_valid() {
|
||||
let config = AcpConfig::http("https://localhost:3000");
|
||||
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_config_default() {
|
||||
let retry = RetryConfig::default();
|
||||
|
||||
assert_eq!(retry.max_retries, 5);
|
||||
// Default delays: 60 seconds each to avoid spam when SSE disconnects
|
||||
assert_eq!(retry.retry_delays_ms, vec![60000, 60000, 60000, 60000, 60000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_config_serialization() {
|
||||
let config = AcpConfig::stdio("test-agent", vec!["--flag".to_string()]);
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: AcpConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.protocol_version, config.protocol_version);
|
||||
assert_eq!(deserialized.cwd, config.cwd);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
//! Error types for ACP connector
|
||||
//!
|
||||
//! This module defines error types specific to ACP connector operations,
|
||||
//! including transport failures, protocol errors, and configuration issues.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type for ACP connector operations
|
||||
pub type AcpResult<T> = Result<T, AcpError>;
|
||||
|
||||
/// Errors that can occur during ACP connector operations
|
||||
///
|
||||
/// This error type covers all failure modes of the ACP connector, from
|
||||
/// low-level transport issues to high-level protocol violations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AcpError {
|
||||
/// Transport layer error (connection failure, I/O error, etc.)
|
||||
///
|
||||
/// Wraps errors from the transport layer (stdio or HTTP) that prevent
|
||||
/// communication with the agent.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - Process failed to spawn
|
||||
/// - Network connection refused
|
||||
/// - Broken pipe (process terminated)
|
||||
/// - SSL/TLS handshake failed
|
||||
#[error("Transport error: {0}")]
|
||||
Transport(String),
|
||||
|
||||
/// Protocol layer error (JSON-RPC format, invalid response, etc.)
|
||||
///
|
||||
/// Indicates a violation of the ACP/JSON-RPC protocol, such as
|
||||
/// malformed messages or unexpected response structure.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - Invalid JSON syntax
|
||||
/// - Missing required JSON-RPC fields
|
||||
/// - Incorrect message format
|
||||
/// - Protocol version mismatch
|
||||
#[error("Protocol error: {0}")]
|
||||
Protocol(String),
|
||||
|
||||
/// Request timeout (no response received within timeout period)
|
||||
///
|
||||
/// The agent did not respond to a request within the configured timeout.
|
||||
/// This may indicate agent unresponsiveness, network issues, or very
|
||||
/// slow operations.
|
||||
#[error("Request timeout after {timeout_ms}ms")]
|
||||
Timeout {
|
||||
/// Timeout duration in milliseconds
|
||||
timeout_ms: u64,
|
||||
},
|
||||
|
||||
/// Configuration error (invalid settings, validation failure, etc.)
|
||||
///
|
||||
/// The connector configuration is invalid or incomplete, preventing
|
||||
/// connector creation or connection attempts.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - Empty command for stdio transport
|
||||
/// - Invalid URL for HTTP transport
|
||||
/// - Zero timeout value
|
||||
/// - Invalid protocol version
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Initialization error (agent initialization failed)
|
||||
///
|
||||
/// The agent rejected the initialization request or returned an error
|
||||
/// during the handshake phase.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - Unsupported protocol version
|
||||
/// - Agent capabilities mismatch
|
||||
/// - Authentication failure
|
||||
#[error("Initialization failed: {0}")]
|
||||
Initialization(String),
|
||||
|
||||
/// Session operation error (session/new, session/prompt, etc.)
|
||||
///
|
||||
/// An error occurred during a session-related operation like creating
|
||||
/// a new session or sending a prompt.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - Session ID not found
|
||||
/// - Session already terminated
|
||||
/// - Invalid prompt format
|
||||
/// - Resource exhaustion
|
||||
#[error("Session operation failed: {0}")]
|
||||
SessionOperation(String),
|
||||
|
||||
/// Internal error (unexpected state, logic error, etc.)
|
||||
///
|
||||
/// An internal error in the connector implementation. This typically
|
||||
/// indicates a bug and should be reported.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - Channel unexpectedly closed
|
||||
/// - Lock poisoned
|
||||
/// - Invalid state transition
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
/// Agent returned a JSON-RPC error response
|
||||
///
|
||||
/// The agent responded with a JSON-RPC error object instead of a
|
||||
/// successful result.
|
||||
#[error("Agent error: {message} (code: {code})")]
|
||||
AgentError {
|
||||
/// JSON-RPC error code
|
||||
code: i64,
|
||||
/// Error message from agent
|
||||
message: String,
|
||||
/// Optional additional error data
|
||||
data: Option<serde_json::Value>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AcpError {
|
||||
/// Create a transport error
|
||||
pub fn transport(msg: impl Into<String>) -> Self {
|
||||
Self::Transport(msg.into())
|
||||
}
|
||||
|
||||
/// Create a protocol error
|
||||
pub fn protocol(msg: impl Into<String>) -> Self {
|
||||
Self::Protocol(msg.into())
|
||||
}
|
||||
|
||||
/// Create a timeout error
|
||||
pub fn timeout(timeout_ms: u64) -> Self {
|
||||
Self::Timeout { timeout_ms }
|
||||
}
|
||||
|
||||
/// Create a configuration error
|
||||
pub fn config(msg: impl Into<String>) -> Self {
|
||||
Self::Config(msg.into())
|
||||
}
|
||||
|
||||
/// Create an initialization error
|
||||
pub fn initialization(msg: impl Into<String>) -> Self {
|
||||
Self::Initialization(msg.into())
|
||||
}
|
||||
|
||||
/// Create a session operation error
|
||||
pub fn session_operation(msg: impl Into<String>) -> Self {
|
||||
Self::SessionOperation(msg.into())
|
||||
}
|
||||
|
||||
/// Create an internal error
|
||||
pub fn internal(msg: impl Into<String>) -> Self {
|
||||
Self::Internal(msg.into())
|
||||
}
|
||||
|
||||
/// Create an agent error from JSON-RPC error object
|
||||
pub fn agent_error(code: i64, message: impl Into<String>, data: Option<serde_json::Value>) -> Self {
|
||||
Self::AgentError {
|
||||
code,
|
||||
message: message.into(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this error is retriable (should attempt reconnection)
|
||||
///
|
||||
/// Returns true for transient errors like transport failures and timeouts.
|
||||
/// Returns false for permanent errors like configuration issues.
|
||||
pub fn is_retriable(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
AcpError::Transport(_) | AcpError::Timeout { .. } | AcpError::Internal(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Conversion from protocol handler errors
|
||||
impl From<crate::connectors::acp::protocol::ProtocolError> for AcpError {
|
||||
fn from(err: crate::connectors::acp::protocol::ProtocolError) -> Self {
|
||||
use crate::connectors::acp::protocol::ProtocolError;
|
||||
|
||||
match err {
|
||||
ProtocolError::Timeout { timeout_ms } => AcpError::Timeout { timeout_ms },
|
||||
ProtocolError::JsonRpcError { code, message, data } => {
|
||||
AcpError::AgentError { code, message, data }
|
||||
}
|
||||
ProtocolError::InvalidMessage(msg) => AcpError::Protocol(msg),
|
||||
ProtocolError::Internal(msg) => AcpError::Internal(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversion from transport errors (boxed error)
|
||||
impl From<Box<dyn std::error::Error + Send + Sync>> for AcpError {
|
||||
fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
|
||||
AcpError::Transport(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transport_error() {
|
||||
let err = AcpError::transport("Connection refused");
|
||||
assert_eq!(err.to_string(), "Transport error: Connection refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protocol_error() {
|
||||
let err = AcpError::protocol("Invalid JSON");
|
||||
assert_eq!(err.to_string(), "Protocol error: Invalid JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_error() {
|
||||
let err = AcpError::timeout(30000);
|
||||
assert_eq!(err.to_string(), "Request timeout after 30000ms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_error() {
|
||||
let err = AcpError::config("Empty command");
|
||||
assert_eq!(err.to_string(), "Configuration error: Empty command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialization_error() {
|
||||
let err = AcpError::initialization("Protocol version mismatch");
|
||||
assert_eq!(err.to_string(), "Initialization failed: Protocol version mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_operation_error() {
|
||||
let err = AcpError::session_operation("Session not found");
|
||||
assert_eq!(err.to_string(), "Session operation failed: Session not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_internal_error() {
|
||||
let err = AcpError::internal("Channel closed");
|
||||
assert_eq!(err.to_string(), "Internal error: Channel closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_error() {
|
||||
let err = AcpError::agent_error(-32601, "Method not found", None);
|
||||
assert_eq!(err.to_string(), "Agent error: Method not found (code: -32601)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retriable() {
|
||||
assert!(AcpError::transport("test").is_retriable());
|
||||
assert!(AcpError::timeout(1000).is_retriable());
|
||||
assert!(AcpError::internal("test").is_retriable());
|
||||
|
||||
assert!(!AcpError::config("test").is_retriable());
|
||||
assert!(!AcpError::protocol("test").is_retriable());
|
||||
assert!(!AcpError::initialization("test").is_retriable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_protocol_error_timeout() {
|
||||
use crate::connectors::acp::protocol::ProtocolError;
|
||||
|
||||
let protocol_err = ProtocolError::Timeout { timeout_ms: 5000 };
|
||||
let acp_err: AcpError = protocol_err.into();
|
||||
|
||||
match acp_err {
|
||||
AcpError::Timeout { timeout_ms } => assert_eq!(timeout_ms, 5000),
|
||||
_ => panic!("Expected Timeout variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_protocol_error_jsonrpc() {
|
||||
use crate::connectors::acp::protocol::ProtocolError;
|
||||
|
||||
let protocol_err = ProtocolError::JsonRpcError {
|
||||
code: -32600,
|
||||
message: "Invalid Request".to_string(),
|
||||
data: None,
|
||||
};
|
||||
let acp_err: AcpError = protocol_err.into();
|
||||
|
||||
match acp_err {
|
||||
AcpError::AgentError { code, message, .. } => {
|
||||
assert_eq!(code, -32600);
|
||||
assert_eq!(message, "Invalid Request");
|
||||
}
|
||||
_ => panic!("Expected AgentError variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Idle Session Detection
|
||||
//!
|
||||
//! This module provides utilities for detecting idle sessions and emitting
|
||||
//! SessionIdle events after a period of inactivity.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::sharing::bus::SharingBus;
|
||||
use dirigent_protocol::{Event, Message, TurnCompleteTrigger};
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use tracing::{info, trace};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Duration of notification silence before emitting SessionIdle
|
||||
/// 300ms is a good balance between responsiveness and not being too aggressive
|
||||
pub const IDLE_THRESHOLD: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Data needed to emit a deferred MessageCompleted event
|
||||
/// Stored until SessionIdle is ready to emit, ensuring all notifications are processed
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingCompletion {
|
||||
pub connector_id: String,
|
||||
pub message: Message,
|
||||
}
|
||||
|
||||
/// Per-session state for activity tracking and idle detection
|
||||
#[derive(Debug)]
|
||||
pub struct SessionState {
|
||||
/// Last time a notification was received for this session
|
||||
last_activity: Instant,
|
||||
/// True after JSON-RPC response received, false after SessionIdle emitted
|
||||
awaiting_idle: bool,
|
||||
/// Pending MessageCompleted to emit when SessionIdle fires
|
||||
/// This delays MessageCompleted until all notifications are processed
|
||||
pub pending_completion: Option<PendingCompletion>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
/// Create a new session state
|
||||
pub fn new(_id: String) -> Self {
|
||||
Self {
|
||||
last_activity: Instant::now(),
|
||||
awaiting_idle: false,
|
||||
pending_completion: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update activity timestamp (call when notification received)
|
||||
pub fn touch(&mut self) {
|
||||
self.last_activity = Instant::now();
|
||||
}
|
||||
|
||||
/// Mark session as awaiting idle emission
|
||||
pub fn mark_awaiting_idle(&mut self) {
|
||||
self.awaiting_idle = true;
|
||||
self.last_activity = Instant::now();
|
||||
}
|
||||
|
||||
/// Clear awaiting idle flag (after SessionIdle emitted)
|
||||
pub fn clear_awaiting_idle(&mut self) {
|
||||
self.awaiting_idle = false;
|
||||
}
|
||||
|
||||
/// Check if session should emit SessionIdle
|
||||
pub fn should_emit_idle(&self, threshold: Duration) -> bool {
|
||||
self.awaiting_idle && self.last_activity.elapsed() >= threshold
|
||||
}
|
||||
|
||||
/// Get time since last activity
|
||||
pub fn elapsed(&self) -> Duration {
|
||||
self.last_activity.elapsed()
|
||||
}
|
||||
|
||||
/// Check if session is awaiting idle
|
||||
pub fn is_awaiting_idle(&self) -> bool {
|
||||
self.awaiting_idle
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all sessions and emit SessionIdle for those with no recent activity
|
||||
///
|
||||
/// For sessions with pending MessageCompleted, emits MessageCompleted BEFORE SessionIdle.
|
||||
/// This ensures all notifications are processed before finalization signals are sent.
|
||||
///
|
||||
/// Returns the list of session IDs that transitioned to idle.
|
||||
pub async fn check_idle_sessions(
|
||||
session_states: &Arc<Mutex<HashMap<String, SessionState>>>,
|
||||
events_tx: &broadcast::Sender<Event>,
|
||||
sharing_bus: &Arc<SharingBus>,
|
||||
connector_uid: Option<Uuid>,
|
||||
connector_id: &str,
|
||||
) -> Vec<String> {
|
||||
let now = Instant::now();
|
||||
let mut sessions_to_idle: Vec<(String, Option<PendingCompletion>)> = Vec::new();
|
||||
|
||||
// Collect sessions that should emit idle, along with any pending completions
|
||||
{
|
||||
let mut states = session_states.lock().await;
|
||||
for (session_id, state) in states.iter_mut() {
|
||||
// Add trace logging for debugging idle detection
|
||||
if state.awaiting_idle {
|
||||
let elapsed = now.duration_since(state.last_activity);
|
||||
trace!(
|
||||
session_id = %session_id,
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
threshold_ms = IDLE_THRESHOLD.as_millis(),
|
||||
has_pending = state.pending_completion.is_some(),
|
||||
"Checking session idle status"
|
||||
);
|
||||
}
|
||||
|
||||
if state.should_emit_idle(IDLE_THRESHOLD) {
|
||||
// Take pending_completion (moves ownership out)
|
||||
let pending = state.pending_completion.take();
|
||||
sessions_to_idle.push((session_id.clone(), pending));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut idled_sessions = Vec::with_capacity(sessions_to_idle.len());
|
||||
|
||||
// Emit MessageCompleted (if pending) then TurnComplete then SessionIdle for each qualifying session
|
||||
for (session_id, pending_completion) in sessions_to_idle {
|
||||
// IMPORTANT: Emit MessageCompleted BEFORE TurnComplete and SessionIdle
|
||||
// This ensures archivist receives complete message data before finalization
|
||||
if let Some(pending) = pending_completion {
|
||||
info!(
|
||||
session_id = %session_id,
|
||||
message_id = %pending.message.id,
|
||||
"Emitting deferred MessageCompleted after {:?} of inactivity",
|
||||
IDLE_THRESHOLD
|
||||
);
|
||||
|
||||
let completed_event = Event::MessageCompleted {
|
||||
connector_id: pending.connector_id.clone(),
|
||||
message: pending.message.clone(),
|
||||
};
|
||||
let bus_event = dirigent_protocol::streaming::BusEvent::from_connector_event(
|
||||
completed_event.clone(),
|
||||
connector_uid,
|
||||
connector_id.to_string(),
|
||||
);
|
||||
sharing_bus.publish(bus_event).await;
|
||||
let _ = events_tx.send(completed_event);
|
||||
|
||||
// Also emit TurnComplete with ResponseReceived trigger
|
||||
// This signals that the turn is truly complete (ACP response received)
|
||||
info!(
|
||||
session_id = %session_id,
|
||||
message_id = %pending.message.id,
|
||||
"Emitting TurnComplete with ResponseReceived trigger",
|
||||
);
|
||||
|
||||
let turn_event = Event::TurnComplete {
|
||||
connector_id: pending.connector_id,
|
||||
session_id: session_id.clone(),
|
||||
message_id: pending.message.id,
|
||||
trigger: TurnCompleteTrigger::ResponseReceived,
|
||||
};
|
||||
let bus_event = dirigent_protocol::streaming::BusEvent::from_connector_event(
|
||||
turn_event.clone(),
|
||||
connector_uid,
|
||||
connector_id.to_string(),
|
||||
);
|
||||
sharing_bus.publish(bus_event).await;
|
||||
let _ = events_tx.send(turn_event);
|
||||
}
|
||||
|
||||
// Then emit SessionIdle
|
||||
info!(
|
||||
session_id = %session_id,
|
||||
"Emitting SessionIdle after {:?} of inactivity",
|
||||
IDLE_THRESHOLD
|
||||
);
|
||||
|
||||
let idle_event = Event::SessionIdle {
|
||||
connector_id: connector_id.to_string(),
|
||||
session_id: session_id.clone(),
|
||||
};
|
||||
let bus_event = dirigent_protocol::streaming::BusEvent::from_connector_event(
|
||||
idle_event.clone(),
|
||||
connector_uid,
|
||||
connector_id.to_string(),
|
||||
);
|
||||
sharing_bus.publish(bus_event).await;
|
||||
let _ = events_tx.send(idle_event);
|
||||
|
||||
// Clear the awaiting_idle flag
|
||||
{
|
||||
let mut states = session_states.lock().await;
|
||||
if let Some(state) = states.get_mut(&session_id) {
|
||||
state.clear_awaiting_idle();
|
||||
}
|
||||
}
|
||||
|
||||
idled_sessions.push(session_id);
|
||||
}
|
||||
|
||||
idled_sessions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_session_state_new() {
|
||||
let state = SessionState::new("test".to_string());
|
||||
assert!(!state.is_awaiting_idle());
|
||||
assert!(state.pending_completion.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_state_touch() {
|
||||
let mut state = SessionState::new("test".to_string());
|
||||
let before = state.elapsed();
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
state.touch();
|
||||
let after = state.elapsed();
|
||||
assert!(after < before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_state_awaiting_idle() {
|
||||
let mut state = SessionState::new("test".to_string());
|
||||
assert!(!state.is_awaiting_idle());
|
||||
|
||||
state.mark_awaiting_idle();
|
||||
assert!(state.is_awaiting_idle());
|
||||
|
||||
state.clear_awaiting_idle();
|
||||
assert!(!state.is_awaiting_idle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_state_should_emit_idle() {
|
||||
let mut state = SessionState::new("test".to_string());
|
||||
|
||||
// Not awaiting idle - should not emit
|
||||
assert!(!state.should_emit_idle(Duration::ZERO));
|
||||
|
||||
// Awaiting idle but not enough time passed
|
||||
state.mark_awaiting_idle();
|
||||
assert!(!state.should_emit_idle(Duration::from_secs(10)));
|
||||
|
||||
// Awaiting idle with zero threshold - should emit immediately
|
||||
assert!(state.should_emit_idle(Duration::ZERO));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! ACP Protocol Logging
|
||||
//!
|
||||
//! This module provides file-based logging of all ACP protocol messages (stdin/stdout)
|
||||
//! for debugging and analysis. When enabled via configuration, all JSON-RPC messages
|
||||
//! are logged to JSONL files in the specified directory.
|
||||
//!
|
||||
//! # Log Format
|
||||
//!
|
||||
//! Logs are written in JSONL (newline-delimited JSON) format:
|
||||
//! ```json
|
||||
//! {"ts":"2026-01-10T12:34:56.789Z","dir":"out","msg":{...json-rpc message...}}
|
||||
//! {"ts":"2026-01-10T12:34:57.001Z","dir":"in","msg":{...json-rpc message...}}
|
||||
//! {"ts":"2026-01-10T12:34:58.123Z","dir":"in","raw":"not valid json","parse_error":"..."}
|
||||
//! ```
|
||||
//!
|
||||
//! # File Naming
|
||||
//!
|
||||
//! Log files are named `{identifier}_{timestamp}.jsonl`
|
||||
//! - identifier: Command name or connector identifier
|
||||
//! - timestamp: ISO 8601 timestamp when logging started
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// ACP Protocol Logger
|
||||
///
|
||||
/// Logs all JSON-RPC messages to files for debugging.
|
||||
/// Owned by the transport - no Arc/Mutex needed.
|
||||
pub struct AcpProtocolLogger {
|
||||
/// Current log file handle
|
||||
file: BufWriter<File>,
|
||||
/// Path to the log file (for logging)
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl AcpProtocolLogger {
|
||||
/// Create a new logger and open the log file immediately
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `log_dir` - Directory to store log files (will be created if it doesn't exist)
|
||||
/// * `identifier` - Identifier for file naming (e.g., command name)
|
||||
///
|
||||
/// # Returns
|
||||
/// `Some(logger)` if file was created successfully, `None` otherwise
|
||||
pub fn new(log_dir: PathBuf, identifier: &str) -> Option<Self> {
|
||||
// Create directory if it doesn't exist
|
||||
if let Err(e) = fs::create_dir_all(&log_dir) {
|
||||
warn!(
|
||||
"Failed to create ACP log directory {:?}: {}",
|
||||
log_dir, e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
Self::cleanup_old_logs(&log_dir, 100);
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S%.3f").to_string();
|
||||
let safe_identifier = identifier
|
||||
.replace('/', "_")
|
||||
.replace('\\', "_")
|
||||
.replace(':', "_");
|
||||
let filename = format!("{}_{}.jsonl", safe_identifier, timestamp);
|
||||
let path = log_dir.join(filename);
|
||||
|
||||
match File::create(&path) {
|
||||
Ok(file) => {
|
||||
info!("ACP protocol logging to {:?}", path);
|
||||
Some(Self {
|
||||
file: BufWriter::new(file),
|
||||
path,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create ACP log file {:?}: {}", path, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an outgoing message (to the agent)
|
||||
pub fn log_outgoing(&mut self, message: &Value) {
|
||||
let entry = serde_json::json!({
|
||||
"ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
"dir": "out",
|
||||
"msg": message
|
||||
});
|
||||
self.write_entry(&entry);
|
||||
}
|
||||
|
||||
/// Log an incoming raw line
|
||||
///
|
||||
/// Tries to parse as JSON. If successful, logs the parsed message.
|
||||
/// If parsing fails, logs the raw line with the parse error.
|
||||
pub fn log_incoming_raw(&mut self, raw_line: &str) {
|
||||
match serde_json::from_str::<Value>(raw_line) {
|
||||
Ok(message) => {
|
||||
let entry = serde_json::json!({
|
||||
"ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
"dir": "in",
|
||||
"msg": message
|
||||
});
|
||||
self.write_entry(&entry);
|
||||
}
|
||||
Err(e) => {
|
||||
let entry = serde_json::json!({
|
||||
"ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
"dir": "in",
|
||||
"raw": raw_line,
|
||||
"parse_error": e.to_string()
|
||||
});
|
||||
self.write_entry(&entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an incoming parsed message (when already parsed)
|
||||
pub fn log_incoming(&mut self, message: &Value) {
|
||||
let entry = serde_json::json!({
|
||||
"ts": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
"dir": "in",
|
||||
"msg": message
|
||||
});
|
||||
self.write_entry(&entry);
|
||||
}
|
||||
|
||||
fn cleanup_old_logs(log_dir: &PathBuf, max_files: usize) {
|
||||
let entries: Vec<_> = match fs::read_dir(log_dir) {
|
||||
Ok(rd) => rd
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "jsonl")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if entries.len() <= max_files {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut by_modified: Vec<_> = entries
|
||||
.into_iter()
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (e, t)))
|
||||
.collect();
|
||||
by_modified.sort_by_key(|(_, t)| *t);
|
||||
|
||||
let to_remove = by_modified.len().saturating_sub(max_files);
|
||||
let mut removed = 0;
|
||||
for (entry, _) in by_modified.into_iter().take(to_remove) {
|
||||
if fs::remove_file(entry.path()).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
info!("Cleaned up {} old ACP log files from {:?}", removed, log_dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_entry(&mut self, entry: &Value) {
|
||||
if let Err(e) = writeln!(self.file, "{}", entry.to_string()) {
|
||||
error!("Failed to write ACP log entry: {}", e);
|
||||
} else if let Err(e) = self.file.flush() {
|
||||
debug!("Failed to flush ACP log: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the log file path
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AcpProtocolLogger {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.file.flush() {
|
||||
warn!("Failed to flush ACP log on close: {}", e);
|
||||
}
|
||||
info!("Closed ACP protocol log: {:?}", self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_logger_creates_directory_and_file() {
|
||||
let temp = tempdir().unwrap();
|
||||
let log_dir = temp.path().join("acp_logs");
|
||||
|
||||
let logger = AcpProtocolLogger::new(log_dir.clone(), "test-command");
|
||||
assert!(logger.is_some());
|
||||
assert!(log_dir.exists());
|
||||
|
||||
// Should have created a file
|
||||
let entries: Vec<_> = fs::read_dir(&log_dir).unwrap().collect();
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logger_writes_messages() {
|
||||
let temp = tempdir().unwrap();
|
||||
let log_dir = temp.path().join("acp_logs");
|
||||
|
||||
let mut logger = AcpProtocolLogger::new(log_dir.clone(), "test-command").unwrap();
|
||||
|
||||
let msg = serde_json::json!({"jsonrpc": "2.0", "method": "test", "id": 1});
|
||||
logger.log_outgoing(&msg);
|
||||
logger.log_incoming(&serde_json::json!({"jsonrpc": "2.0", "result": "ok", "id": 1}));
|
||||
drop(logger); // Flush on drop
|
||||
|
||||
// Read the file
|
||||
let entries: Vec<_> = fs::read_dir(&log_dir).unwrap().collect();
|
||||
let file_path = entries[0].as_ref().unwrap().path();
|
||||
let content = fs::read_to_string(file_path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].contains("\"dir\":\"out\""));
|
||||
assert!(lines[1].contains("\"dir\":\"in\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logger_handles_raw_parse_error() {
|
||||
let temp = tempdir().unwrap();
|
||||
let log_dir = temp.path().join("acp_logs");
|
||||
|
||||
let mut logger = AcpProtocolLogger::new(log_dir.clone(), "test-command").unwrap();
|
||||
|
||||
// Log a valid JSON
|
||||
logger.log_incoming_raw(r#"{"jsonrpc":"2.0","result":"ok","id":1}"#);
|
||||
// Log invalid JSON (raw output from crashed process)
|
||||
logger.log_incoming_raw("Error: process crashed with SIGSEGV");
|
||||
drop(logger);
|
||||
|
||||
// Read the file
|
||||
let entries: Vec<_> = fs::read_dir(&log_dir).unwrap().collect();
|
||||
let file_path = entries[0].as_ref().unwrap().path();
|
||||
let content = fs::read_to_string(file_path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
assert_eq!(lines.len(), 2);
|
||||
// First line should be parsed JSON
|
||||
assert!(lines[0].contains("\"msg\":"));
|
||||
assert!(!lines[0].contains("\"parse_error\""));
|
||||
// Second line should have raw + parse_error
|
||||
assert!(lines[1].contains("\"raw\":"));
|
||||
assert!(lines[1].contains("\"parse_error\":"));
|
||||
assert!(lines[1].contains("SIGSEGV"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! ACP (Agent-Client Protocol) connector module
|
||||
//!
|
||||
//! This module provides a complete connector implementation for the Agent-Client Protocol,
|
||||
//! which is a standardized protocol for communicating with AI agents.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The ACP connector consists of multiple layers:
|
||||
//!
|
||||
//! ## Transport Layer (`transport`)
|
||||
//! Provides low-level communication mechanisms:
|
||||
//! - **StdioTransport**: Line-delimited JSON over stdin/stdout (for spawned processes)
|
||||
//! - **HttpTransport**: HTTP POST for requests + SSE for notifications
|
||||
//!
|
||||
//! ## Protocol Layer (`protocol`)
|
||||
//! Provides JSON-RPC request/response correlation:
|
||||
//! - **ProtocolHandler**: Correlates requests with responses by ID
|
||||
//! - Request timeout handling (default 30s)
|
||||
//! - Notification routing (server-initiated messages)
|
||||
//! - Helper functions for building ACP messages
|
||||
//!
|
||||
//! ## Configuration (`config`)
|
||||
//! Configuration types for connector creation:
|
||||
//! - **AcpConfig**: Main configuration struct
|
||||
//! - **TransportKind**: Transport selection (Stdio vs HTTP)
|
||||
//! - **RetryConfig**: Reconnection behavior
|
||||
//!
|
||||
//! ## Error Handling (`error`)
|
||||
//! Error types for all connector operations:
|
||||
//! - **AcpError**: Comprehensive error enum
|
||||
//! - **AcpResult**: Result type alias
|
||||
//!
|
||||
//! ## State Management (`state`)
|
||||
//! Internal state tracking:
|
||||
//! - **InternalState**: Thread-safe state container
|
||||
//! - **SessionInfo**: Session metadata
|
||||
//!
|
||||
//! ## Connector Implementation (`connector`)
|
||||
//! Main connector implementation:
|
||||
//! - **AcpConnector**: Implements `Connector` trait
|
||||
//! - Background task for event processing
|
||||
//! - Automatic reconnection with exponential backoff
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use dirigent_core::connectors::acp::{AcpConnector, AcpConfig};
|
||||
//! use dirigent_core::connectors::Connector;
|
||||
//!
|
||||
//! # async fn example() -> anyhow::Result<()> {
|
||||
//! // Create connector config
|
||||
//! let config = AcpConfig::stdio("dirigate", vec!["serve".to_string(), "--stdio".to_string()]);
|
||||
//!
|
||||
//! // Create connector
|
||||
//! let connector = AcpConnector::new(
|
||||
//! "acp-1".to_string(),
|
||||
//! uuid::Uuid::now_v7(),
|
||||
//! "ACP Agent".to_string(),
|
||||
//! config,
|
||||
//! )?;
|
||||
//!
|
||||
//! // Start background task
|
||||
//! let task_handle = connector.start_task().await;
|
||||
//!
|
||||
//! // Subscribe to events
|
||||
//! let mut events = connector.subscribe();
|
||||
//!
|
||||
//! // Send commands
|
||||
//! let cmd_tx = connector.command_tx();
|
||||
//! // ... use connector ...
|
||||
//!
|
||||
//! // Stop connector
|
||||
//! connector.stop();
|
||||
//! task_handle.await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod config;
|
||||
pub mod connector;
|
||||
pub mod error;
|
||||
pub mod idle_detector;
|
||||
pub mod logging;
|
||||
pub mod protocol;
|
||||
pub mod state;
|
||||
pub mod title_utils;
|
||||
pub mod transport;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use config::{features, AcpConfig, RetryConfig, TransportKind};
|
||||
pub use connector::AcpConnector;
|
||||
pub use error::{AcpError, AcpResult};
|
||||
pub use logging::AcpProtocolLogger;
|
||||
pub use protocol::{
|
||||
build_initialize_request, build_session_cancel_request, build_session_load_request,
|
||||
build_session_new_request, build_session_prompt_request, ProtocolError, ProtocolHandler,
|
||||
ProtocolResult, RequestId,
|
||||
};
|
||||
pub use state::{InternalState, SessionInfo, SessionStatus};
|
||||
pub use transport::{AcpTransport, HttpTransport, StdioTransport, TransportResult};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,977 @@
|
||||
//! Internal state management for ACP connector
|
||||
//!
|
||||
//! This module provides thread-safe state tracking for the ACP connector,
|
||||
//! including protocol version, agent capabilities, and active sessions.
|
||||
|
||||
use chrono::Utc;
|
||||
use dirigent_protocol::{SessionModeState, SessionModelState, SessionOwnership};
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Internal state for ACP connector
|
||||
///
|
||||
/// Tracks connection state, protocol information, and active sessions.
|
||||
/// All fields are protected by RwLock for thread-safe concurrent access.
|
||||
///
|
||||
/// # Thread Safety
|
||||
///
|
||||
/// This struct is designed to be shared across async tasks via `Arc`.
|
||||
/// All mutations go through interior mutability patterns (RwLock).
|
||||
#[derive(Debug)]
|
||||
pub struct InternalState {
|
||||
/// Negotiated protocol version
|
||||
///
|
||||
/// Set during initialization handshake. None if not yet initialized.
|
||||
protocol_version: Arc<RwLock<Option<u32>>>,
|
||||
|
||||
/// Agent capabilities
|
||||
///
|
||||
/// Populated from the initialize response. Contains information about
|
||||
/// what the agent supports (streaming, tools, MCP servers, etc.).
|
||||
agent_capabilities: Arc<RwLock<Option<Value>>>,
|
||||
|
||||
/// Active sessions
|
||||
///
|
||||
/// Map of session ID to session metadata. Updated as sessions are
|
||||
/// created, updated, or terminated.
|
||||
sessions: Arc<RwLock<HashMap<String, SessionInfo>>>,
|
||||
|
||||
/// Active message IDs per session
|
||||
///
|
||||
/// Tracks the current Dirigent message_id being accumulated for each session.
|
||||
/// This allows us to translate many ACP chunks (each with different messageIds)
|
||||
/// into a single Dirigent message with one consistent message_id.
|
||||
active_messages: Arc<RwLock<HashMap<String, String>>>,
|
||||
|
||||
/// Message start times
|
||||
///
|
||||
/// Tracks when each message started (when the first chunk arrived).
|
||||
/// Maps message_id to timestamp. This allows us to provide accurate
|
||||
/// created_at timestamps for messages instead of using finalization time.
|
||||
message_start_times: Arc<RwLock<HashMap<String, chrono::DateTime<chrono::Utc>>>>,
|
||||
|
||||
/// Loaded sessions
|
||||
///
|
||||
/// Tracks which sessions have been successfully loaded (via session/load
|
||||
/// or session/resume) during this connection. Used to decide whether to
|
||||
/// load or resume a session: if already loaded, resume; otherwise, load first.
|
||||
/// Cleared on reconnect.
|
||||
loaded_sessions: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
/// Information about an active session
|
||||
///
|
||||
/// Lightweight metadata tracked for each session. Does not include
|
||||
/// full message history (that's stored by the agent).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionInfo {
|
||||
/// Unique session identifier
|
||||
pub id: String,
|
||||
|
||||
/// Session title (if available)
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Current working directory for this session
|
||||
pub cwd: String,
|
||||
|
||||
/// Number of messages in this session (approximate)
|
||||
pub message_count: u32,
|
||||
|
||||
/// Session model (if available) - legacy field for simple model name
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Creation timestamp
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
/// Last activity timestamp
|
||||
pub last_activity: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
/// Session status
|
||||
pub status: SessionStatus,
|
||||
|
||||
// ========================================================================
|
||||
// ACP Session Metadata (from session/new and session/load responses)
|
||||
// ========================================================================
|
||||
|
||||
/// Available models and current model selection
|
||||
///
|
||||
/// Captured from ACP `session/new` or `session/load` response.
|
||||
/// Note: This field is marked UNSTABLE in the ACP spec but Claude-ACP uses it.
|
||||
pub models: Option<SessionModelState>,
|
||||
|
||||
/// Available modes and current mode selection
|
||||
///
|
||||
/// Captured from ACP `session/new` or `session/load` response.
|
||||
/// This is part of the stable ACP specification.
|
||||
pub modes: Option<SessionModeState>,
|
||||
|
||||
/// ACP config options (replaces modes/models in future ACP versions)
|
||||
///
|
||||
/// Captured from ACP `session/new` or `session/load` response `configOptions` field.
|
||||
pub config_options: Option<Vec<dirigent_protocol::ConfigOption>>,
|
||||
|
||||
/// Ownership model for this session
|
||||
///
|
||||
/// Tracks whether this session is internal to Dirigent or originates from
|
||||
/// an external client, and how tool calls should be handled.
|
||||
pub ownership: SessionOwnership,
|
||||
}
|
||||
|
||||
/// Status of a session
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionStatus {
|
||||
/// Session is active and can accept prompts
|
||||
Active,
|
||||
|
||||
/// Session is processing a prompt (agent is responding)
|
||||
Processing,
|
||||
|
||||
/// Session is idle (no active processing)
|
||||
Idle,
|
||||
|
||||
/// Session has ended
|
||||
Ended,
|
||||
}
|
||||
|
||||
impl InternalState {
|
||||
/// Create a new empty state
|
||||
///
|
||||
/// All fields are initialized to None/empty. State is populated
|
||||
/// as the connector progresses through initialization and operation.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
protocol_version: Arc::new(RwLock::new(None)),
|
||||
agent_capabilities: Arc::new(RwLock::new(None)),
|
||||
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||
active_messages: Arc::new(RwLock::new(HashMap::new())),
|
||||
message_start_times: Arc::new(RwLock::new(HashMap::new())),
|
||||
loaded_sessions: Arc::new(RwLock::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the negotiated protocol version
|
||||
///
|
||||
/// Returns None if initialization has not completed.
|
||||
pub async fn protocol_version(&self) -> Option<u32> {
|
||||
*self.protocol_version.read().await
|
||||
}
|
||||
|
||||
/// Set the negotiated protocol version
|
||||
///
|
||||
/// Called after successful initialization handshake.
|
||||
pub async fn set_protocol_version(&self, version: u32) {
|
||||
let mut guard = self.protocol_version.write().await;
|
||||
*guard = Some(version);
|
||||
}
|
||||
|
||||
/// Get the agent capabilities
|
||||
///
|
||||
/// Returns None if initialization has not completed.
|
||||
pub async fn agent_capabilities(&self) -> Option<Value> {
|
||||
self.agent_capabilities.read().await.clone()
|
||||
}
|
||||
|
||||
/// Set the agent capabilities
|
||||
///
|
||||
/// Called after successful initialization handshake.
|
||||
pub async fn set_agent_capabilities(&self, capabilities: Value) {
|
||||
let mut guard = self.agent_capabilities.write().await;
|
||||
*guard = Some(capabilities);
|
||||
}
|
||||
|
||||
/// Add or update a session
|
||||
///
|
||||
/// If the session ID already exists, the info is updated.
|
||||
/// Otherwise, a new entry is created.
|
||||
pub async fn upsert_session(&self, info: SessionInfo) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.insert(info.id.clone(), info);
|
||||
}
|
||||
|
||||
/// Remove a session
|
||||
///
|
||||
/// Called when a session is terminated or deleted.
|
||||
pub async fn remove_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.remove(session_id);
|
||||
}
|
||||
|
||||
/// Get a specific session
|
||||
///
|
||||
/// Returns None if the session is not tracked.
|
||||
pub async fn get_session(&self, session_id: &str) -> Option<SessionInfo> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.get(session_id).cloned()
|
||||
}
|
||||
|
||||
/// List all tracked sessions
|
||||
///
|
||||
/// Returns a snapshot of all session info. The returned Vec is
|
||||
/// independent of the internal state and can be safely modified.
|
||||
pub async fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get the number of tracked sessions
|
||||
pub async fn session_count(&self) -> usize {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.len()
|
||||
}
|
||||
|
||||
/// Update message count for a session
|
||||
///
|
||||
/// Convenience method to increment or set message count without
|
||||
/// replacing the entire SessionInfo.
|
||||
pub async fn update_message_count(&self, session_id: &str, count: u32) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(info) = sessions.get_mut(session_id) {
|
||||
info.message_count = count;
|
||||
info.last_activity = chrono::Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a session is active
|
||||
///
|
||||
/// Returns true if the session exists and is not in Ended status.
|
||||
pub async fn is_session_active(&self, session_id: &str) -> bool {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions
|
||||
.get(session_id)
|
||||
.map(|info| info.status != SessionStatus::Ended)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Update session status
|
||||
///
|
||||
/// Convenience method to update session status and last activity time.
|
||||
pub async fn update_session_status(&self, session_id: &str, status: SessionStatus) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(info) = sessions.get_mut(session_id) {
|
||||
info.status = status;
|
||||
info.last_activity = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Touch session (update last activity timestamp)
|
||||
///
|
||||
/// Called when any activity occurs on a session.
|
||||
pub async fn touch_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(info) = sessions.get_mut(session_id) {
|
||||
info.last_activity = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create active message ID for a session
|
||||
///
|
||||
/// If no message is currently being accumulated for this session, generates
|
||||
/// a new UUID-based Dirigent message_id and stores it. Returns the active
|
||||
/// message_id (either existing or newly created).
|
||||
///
|
||||
/// This allows us to translate multiple ACP chunks (each with different
|
||||
/// ACP messageIds) into a single Dirigent message with one consistent ID.
|
||||
pub async fn get_or_create_active_message(&self, session_id: &str) -> String {
|
||||
let mut active_messages = self.active_messages.write().await;
|
||||
|
||||
// Check if this is a new message BEFORE inserting
|
||||
let is_new_message = !active_messages.contains_key(session_id);
|
||||
|
||||
let message_id = active_messages
|
||||
.entry(session_id.to_string())
|
||||
.or_insert_with(|| format!("msg-{}", uuid::Uuid::now_v7()))
|
||||
.clone();
|
||||
|
||||
// Track message start time when creating new message
|
||||
if is_new_message {
|
||||
let mut start_times = self.message_start_times.write().await;
|
||||
start_times.insert(message_id.clone(), Utc::now());
|
||||
}
|
||||
|
||||
message_id
|
||||
}
|
||||
|
||||
/// Clear active message ID for a session
|
||||
///
|
||||
/// Called when a message is completed (after receiving prompt response).
|
||||
/// This allows the next message to get a fresh Dirigent message_id.
|
||||
pub async fn clear_active_message(&self, session_id: &str) -> Option<String> {
|
||||
let mut active_messages = self.active_messages.write().await;
|
||||
active_messages.remove(session_id)
|
||||
}
|
||||
|
||||
/// Get active message ID for a session (if any)
|
||||
///
|
||||
/// Returns None if no message is currently being accumulated.
|
||||
pub async fn get_active_message(&self, session_id: &str) -> Option<String> {
|
||||
let active_messages = self.active_messages.read().await;
|
||||
active_messages.get(session_id).cloned()
|
||||
}
|
||||
|
||||
/// Get message start time
|
||||
///
|
||||
/// Returns the timestamp when the message started (first chunk arrived).
|
||||
/// Returns None if the message is not tracked.
|
||||
pub async fn get_message_start_time(&self, message_id: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let start_times = self.message_start_times.read().await;
|
||||
start_times.get(message_id).copied()
|
||||
}
|
||||
|
||||
/// Clear message start time
|
||||
///
|
||||
/// Called after a message is finalized and archived.
|
||||
/// This prevents memory leaks from accumulating start times.
|
||||
pub async fn clear_message_start_time(&self, message_id: &str) {
|
||||
let mut start_times = self.message_start_times.write().await;
|
||||
start_times.remove(message_id);
|
||||
}
|
||||
|
||||
/// Clear all state
|
||||
///
|
||||
/// Resets the state to its initial empty condition. Called on
|
||||
/// reconnection or when recovering from errors.
|
||||
pub async fn clear(&self) {
|
||||
let mut protocol_version = self.protocol_version.write().await;
|
||||
*protocol_version = None;
|
||||
|
||||
let mut capabilities = self.agent_capabilities.write().await;
|
||||
*capabilities = None;
|
||||
|
||||
let mut sessions = self.sessions.write().await;
|
||||
sessions.clear();
|
||||
|
||||
let mut active_messages = self.active_messages.write().await;
|
||||
active_messages.clear();
|
||||
|
||||
let mut start_times = self.message_start_times.write().await;
|
||||
start_times.clear();
|
||||
|
||||
let mut loaded = self.loaded_sessions.write().await;
|
||||
loaded.clear();
|
||||
}
|
||||
|
||||
/// Check if the connector is initialized
|
||||
///
|
||||
/// Returns true if we have completed the initialization handshake
|
||||
/// (protocol version and capabilities are set).
|
||||
pub async fn is_initialized(&self) -> bool {
|
||||
self.protocol_version().await.is_some()
|
||||
}
|
||||
|
||||
/// Check if the upstream agent supports session/load
|
||||
///
|
||||
/// Returns true if `agentCapabilities.loadSession` is `true`.
|
||||
pub async fn agent_supports_load_session(&self) -> bool {
|
||||
self.agent_capabilities
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.and_then(|caps| caps.get("loadSession"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if the upstream agent supports session/list
|
||||
///
|
||||
/// Checks `sessionCapabilities.list` (spec-correct, object presence) with
|
||||
/// fallback to legacy `listSessions` (boolean).
|
||||
pub async fn agent_supports_list_sessions(&self) -> bool {
|
||||
self.agent_capabilities.read().await.as_ref()
|
||||
.map(|caps| {
|
||||
// Spec-correct: sessionCapabilities.list (object presence)
|
||||
let nested = caps.get("sessionCapabilities")
|
||||
.and_then(|sc| sc.get("list"))
|
||||
.is_some();
|
||||
// Legacy compat: listSessions (boolean)
|
||||
let flat = caps.get("listSessions")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
nested || flat
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if the upstream agent supports session/resume
|
||||
///
|
||||
/// Returns true if `agentCapabilities.sessionCapabilities.resume` exists.
|
||||
pub async fn agent_supports_session_resume(&self) -> bool {
|
||||
self.agent_capabilities
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.and_then(|caps| caps.get("sessionCapabilities"))
|
||||
.and_then(|sc| sc.get("resume"))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Check if the upstream agent supports session/close
|
||||
///
|
||||
/// Returns true if `agentCapabilities.sessionCapabilities.close` exists.
|
||||
pub async fn agent_supports_session_close(&self) -> bool {
|
||||
self.agent_capabilities
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.and_then(|caps| caps.get("sessionCapabilities"))
|
||||
.and_then(|sc| sc.get("close"))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Mark a session as loaded
|
||||
///
|
||||
/// Called after a successful session/load or session/resume. Subsequent
|
||||
/// operations on this session can use session/resume instead of session/load.
|
||||
pub async fn mark_session_loaded(&self, session_id: &str) {
|
||||
let mut loaded = self.loaded_sessions.write().await;
|
||||
loaded.insert(session_id.to_string());
|
||||
}
|
||||
|
||||
/// Check if a session has been loaded in this connection
|
||||
///
|
||||
/// Returns true if the session was previously loaded via session/load or
|
||||
/// session/resume. Used to decide whether to load or resume.
|
||||
pub async fn is_session_loaded(&self, session_id: &str) -> bool {
|
||||
let loaded = self.loaded_sessions.read().await;
|
||||
loaded.contains(session_id)
|
||||
}
|
||||
|
||||
/// Unmark a session as loaded
|
||||
///
|
||||
/// Called when a session is closed. The next access will require a
|
||||
/// full session/load again.
|
||||
pub async fn unmark_session_loaded(&self, session_id: &str) {
|
||||
let mut loaded = self.loaded_sessions.write().await;
|
||||
loaded.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InternalState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_state_creation() {
|
||||
let state = InternalState::new();
|
||||
|
||||
assert_eq!(state.protocol_version().await, None);
|
||||
assert_eq!(state.agent_capabilities().await, None);
|
||||
assert_eq!(state.session_count().await, 0);
|
||||
assert!(!state.is_initialized().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_protocol_version() {
|
||||
let state = InternalState::new();
|
||||
|
||||
state.set_protocol_version(1).await;
|
||||
|
||||
assert_eq!(state.protocol_version().await, Some(1));
|
||||
assert!(state.is_initialized().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_capabilities() {
|
||||
let state = InternalState::new();
|
||||
|
||||
let caps = serde_json::json!({
|
||||
"streaming": true,
|
||||
"tools": ["bash", "edit"]
|
||||
});
|
||||
|
||||
state.set_agent_capabilities(caps.clone()).await;
|
||||
|
||||
let retrieved = state.agent_capabilities().await;
|
||||
assert_eq!(retrieved, Some(caps));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_management() {
|
||||
let state = InternalState::new();
|
||||
|
||||
let now = Utc::now();
|
||||
let session1 = SessionInfo {
|
||||
id: "session-1".to_string(),
|
||||
title: Some("Test Session".to_string()),
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: Some("claude-3".to_string()),
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: SessionOwnership::default(),
|
||||
};
|
||||
|
||||
let session2 = SessionInfo {
|
||||
id: "session-2".to_string(),
|
||||
title: None,
|
||||
cwd: "/tmp".to_string(),
|
||||
message_count: 5,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Idle,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: SessionOwnership::default(),
|
||||
};
|
||||
|
||||
// Add sessions
|
||||
state.upsert_session(session1.clone()).await;
|
||||
state.upsert_session(session2.clone()).await;
|
||||
|
||||
assert_eq!(state.session_count().await, 2);
|
||||
|
||||
// Get specific session
|
||||
let retrieved = state.get_session("session-1").await;
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().id, "session-1");
|
||||
|
||||
// List all sessions
|
||||
let all_sessions = state.list_sessions().await;
|
||||
assert_eq!(all_sessions.len(), 2);
|
||||
|
||||
// Remove a session
|
||||
state.remove_session("session-1").await;
|
||||
assert_eq!(state.session_count().await, 1);
|
||||
assert!(state.get_session("session-1").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_upsert() {
|
||||
let state = InternalState::new();
|
||||
|
||||
let now = Utc::now();
|
||||
let session = SessionInfo {
|
||||
id: "session-1".to_string(),
|
||||
title: Some("Original".to_string()),
|
||||
cwd: ".".to_string(),
|
||||
message_count: 1,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: SessionOwnership::default(),
|
||||
};
|
||||
|
||||
// Add session
|
||||
state.upsert_session(session.clone()).await;
|
||||
assert_eq!(state.session_count().await, 1);
|
||||
|
||||
// Update session (same ID)
|
||||
let updated = SessionInfo {
|
||||
id: "session-1".to_string(),
|
||||
title: Some("Updated".to_string()),
|
||||
cwd: ".".to_string(),
|
||||
message_count: 2,
|
||||
model: Some("claude-3".to_string()),
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Processing,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: SessionOwnership::default(),
|
||||
};
|
||||
|
||||
state.upsert_session(updated.clone()).await;
|
||||
assert_eq!(state.session_count().await, 1);
|
||||
|
||||
let retrieved = state.get_session("session-1").await.unwrap();
|
||||
assert_eq!(retrieved.title, Some("Updated".to_string()));
|
||||
assert_eq!(retrieved.message_count, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_message_count() {
|
||||
let state = InternalState::new();
|
||||
|
||||
let now = Utc::now();
|
||||
let session = SessionInfo {
|
||||
id: "session-1".to_string(),
|
||||
title: None,
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: SessionOwnership::default(),
|
||||
};
|
||||
|
||||
state.upsert_session(session).await;
|
||||
|
||||
state.update_message_count("session-1", 10).await;
|
||||
|
||||
let retrieved = state.get_session("session-1").await.unwrap();
|
||||
assert_eq!(retrieved.message_count, 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_state() {
|
||||
let state = InternalState::new();
|
||||
|
||||
// Populate state
|
||||
state.set_protocol_version(1).await;
|
||||
state.set_agent_capabilities(serde_json::json!({})).await;
|
||||
let now = Utc::now();
|
||||
state.upsert_session(SessionInfo {
|
||||
id: "session-1".to_string(),
|
||||
title: None,
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
}).await;
|
||||
|
||||
assert!(state.is_initialized().await);
|
||||
assert_eq!(state.session_count().await, 1);
|
||||
|
||||
// Clear everything
|
||||
state.clear().await;
|
||||
|
||||
assert!(!state.is_initialized().await);
|
||||
assert_eq!(state.session_count().await, 0);
|
||||
assert_eq!(state.protocol_version().await, None);
|
||||
assert_eq!(state.agent_capabilities().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_access() {
|
||||
let state = Arc::new(InternalState::new());
|
||||
|
||||
let mut handles = vec![];
|
||||
|
||||
// Spawn 10 tasks that all add sessions
|
||||
for i in 0..10 {
|
||||
let state_clone = Arc::clone(&state);
|
||||
let handle = tokio::spawn(async move {
|
||||
let now = Utc::now();
|
||||
let session = SessionInfo {
|
||||
id: format!("session-{}", i),
|
||||
title: None,
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
};
|
||||
state_clone.upsert_session(session).await;
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
|
||||
// Verify all sessions were added
|
||||
assert_eq!(state.session_count().await, 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_is_session_active() {
|
||||
let state = InternalState::new();
|
||||
let now = Utc::now();
|
||||
|
||||
// Active session
|
||||
state.upsert_session(SessionInfo {
|
||||
id: "active".to_string(),
|
||||
title: None,
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
}).await;
|
||||
|
||||
// Ended session
|
||||
state.upsert_session(SessionInfo {
|
||||
id: "ended".to_string(),
|
||||
title: None,
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Ended,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: SessionOwnership::default(),
|
||||
}).await;
|
||||
|
||||
assert!(state.is_session_active("active").await);
|
||||
assert!(!state.is_session_active("ended").await);
|
||||
assert!(!state.is_session_active("nonexistent").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_session_status() {
|
||||
let state = InternalState::new();
|
||||
let now = Utc::now();
|
||||
|
||||
state.upsert_session(SessionInfo {
|
||||
id: "session-1".to_string(),
|
||||
title: None,
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
}).await;
|
||||
|
||||
// Change status to Processing
|
||||
state.update_session_status("session-1", SessionStatus::Processing).await;
|
||||
|
||||
let session = state.get_session("session-1").await.unwrap();
|
||||
assert_eq!(session.status, SessionStatus::Processing);
|
||||
assert!(session.last_activity > now);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_touch_session() {
|
||||
let state = InternalState::new();
|
||||
let now = Utc::now();
|
||||
|
||||
state.upsert_session(SessionInfo {
|
||||
id: "session-1".to_string(),
|
||||
title: None,
|
||||
cwd: ".".to_string(),
|
||||
message_count: 0,
|
||||
model: None,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
status: SessionStatus::Active,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
}).await;
|
||||
|
||||
// Small delay to ensure timestamp difference
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
|
||||
// Touch the session
|
||||
state.touch_session("session-1").await;
|
||||
|
||||
let session = state.get_session("session-1").await.unwrap();
|
||||
assert!(session.last_activity > now);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_load_session_true() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"loadSession": true
|
||||
})).await;
|
||||
assert!(state.agent_supports_load_session().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_load_session_false() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"loadSession": false
|
||||
})).await;
|
||||
assert!(!state.agent_supports_load_session().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_load_session_missing() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({})).await;
|
||||
assert!(!state.agent_supports_load_session().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_list_sessions_legacy_true() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"listSessions": true
|
||||
})).await;
|
||||
assert!(state.agent_supports_list_sessions().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_list_sessions_legacy_false() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"listSessions": false
|
||||
})).await;
|
||||
assert!(!state.agent_supports_list_sessions().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_list_sessions_nested() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"sessionCapabilities": {
|
||||
"list": {}
|
||||
}
|
||||
})).await;
|
||||
assert!(state.agent_supports_list_sessions().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_list_sessions_nested_with_options() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"sessionCapabilities": {
|
||||
"list": { "supportsCwd": true }
|
||||
}
|
||||
})).await;
|
||||
assert!(state.agent_supports_list_sessions().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_list_sessions_both_formats() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"listSessions": true,
|
||||
"sessionCapabilities": {
|
||||
"list": {}
|
||||
}
|
||||
})).await;
|
||||
assert!(state.agent_supports_list_sessions().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_list_sessions_missing() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({})).await;
|
||||
assert!(!state.agent_supports_list_sessions().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_list_sessions_no_caps() {
|
||||
let state = InternalState::new();
|
||||
assert!(!state.agent_supports_list_sessions().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_session_resume_true() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"sessionCapabilities": {
|
||||
"resume": {}
|
||||
}
|
||||
})).await;
|
||||
assert!(state.agent_supports_session_resume().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_session_resume_missing() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"sessionCapabilities": {}
|
||||
})).await;
|
||||
assert!(!state.agent_supports_session_resume().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_session_resume_no_session_caps() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({})).await;
|
||||
assert!(!state.agent_supports_session_resume().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_session_close_true() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"sessionCapabilities": {
|
||||
"close": {}
|
||||
}
|
||||
})).await;
|
||||
assert!(state.agent_supports_session_close().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_session_close_missing() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({
|
||||
"sessionCapabilities": {}
|
||||
})).await;
|
||||
assert!(!state.agent_supports_session_close().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_supports_session_close_no_session_caps() {
|
||||
let state = InternalState::new();
|
||||
state.set_agent_capabilities(serde_json::json!({})).await;
|
||||
assert!(!state.agent_supports_session_close().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_loaded_session_tracking() {
|
||||
let state = InternalState::new();
|
||||
|
||||
// Initially not loaded
|
||||
assert!(!state.is_session_loaded("sess-1").await);
|
||||
|
||||
// Mark as loaded
|
||||
state.mark_session_loaded("sess-1").await;
|
||||
assert!(state.is_session_loaded("sess-1").await);
|
||||
assert!(!state.is_session_loaded("sess-2").await);
|
||||
|
||||
// Unmark
|
||||
state.unmark_session_loaded("sess-1").await;
|
||||
assert!(!state.is_session_loaded("sess-1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_loaded_sessions_cleared_on_reconnect() {
|
||||
let state = InternalState::new();
|
||||
|
||||
state.mark_session_loaded("sess-1").await;
|
||||
state.mark_session_loaded("sess-2").await;
|
||||
assert!(state.is_session_loaded("sess-1").await);
|
||||
assert!(state.is_session_loaded("sess-2").await);
|
||||
|
||||
// Clear simulates reconnect
|
||||
state.clear().await;
|
||||
|
||||
assert!(!state.is_session_loaded("sess-1").await);
|
||||
assert!(!state.is_session_loaded("sess-2").await);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Session Title Derivation Utilities
|
||||
//!
|
||||
//! This module provides utilities for deriving session titles from user messages.
|
||||
|
||||
/// Maximum length for derived titles
|
||||
pub const MAX_TITLE_LENGTH: usize = 50;
|
||||
|
||||
/// Derive a session title from user message text
|
||||
///
|
||||
/// Takes the first 50 characters, trimming whitespace and trying to break at word boundaries.
|
||||
/// Sanitizes by removing newlines and control characters.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `text` - The user message text to derive title from
|
||||
///
|
||||
/// # Returns
|
||||
/// Derived title (max 50 chars, trimmed, sanitized)
|
||||
pub fn derive_title_from_text(text: &str) -> String {
|
||||
// Sanitize: replace newlines and control chars with space
|
||||
let sanitized = text
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect::<String>();
|
||||
|
||||
// Trim whitespace
|
||||
let trimmed = sanitized.trim();
|
||||
|
||||
// Handle empty case
|
||||
if trimmed.is_empty() {
|
||||
return "Untitled Session".to_string();
|
||||
}
|
||||
|
||||
// If text is short enough, return as-is
|
||||
if trimmed.len() <= MAX_TITLE_LENGTH {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
// Text is longer than MAX_TITLE_LENGTH - truncate at a valid char boundary
|
||||
let end = trimmed.floor_char_boundary(MAX_TITLE_LENGTH);
|
||||
let truncated = &trimmed[..end];
|
||||
|
||||
// Try to break at word boundary within the truncated text
|
||||
if let Some(last_space) = truncated.rfind(' ') {
|
||||
// Break at word boundary
|
||||
trimmed[..last_space].trim_end().to_string()
|
||||
} else {
|
||||
// No spaces found - just truncate
|
||||
truncated.trim_end().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_from_text_short() {
|
||||
let text = "Hello world";
|
||||
let title = derive_title_from_text(text);
|
||||
assert_eq!(title, "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_from_text_long_with_word_boundary() {
|
||||
let text = "This is a very long message that should be truncated at word boundary";
|
||||
let title = derive_title_from_text(text);
|
||||
assert!(title.len() <= 50);
|
||||
assert!(title.starts_with("This is a very long message"));
|
||||
// Should break at word boundary - the exact result depends on word positions
|
||||
// Just verify it doesn't exceed 50 chars and doesn't end mid-word
|
||||
assert!(!title.ends_with("at")); // Shouldn't break "at word"
|
||||
// The title should end on a complete word
|
||||
let last_char = title.chars().last().unwrap();
|
||||
assert!(last_char.is_alphanumeric() || last_char == ' ');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_from_text_long_no_spaces() {
|
||||
let text = "Thisisaverylongmessagewithoutanyspacesthatcannotbetruncatedatwordboundary";
|
||||
let title = derive_title_from_text(text);
|
||||
assert_eq!(title.len(), 50);
|
||||
// Without spaces, it will just truncate at 50 chars
|
||||
assert_eq!(title, &text[..50]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_from_text_with_newlines() {
|
||||
let text = "First line\nSecond line\nThird line";
|
||||
let title = derive_title_from_text(text);
|
||||
// Newlines should be replaced with spaces
|
||||
assert!(!title.contains('\n'));
|
||||
assert!(title.contains("First line Second line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_from_text_empty() {
|
||||
let text = "";
|
||||
let title = derive_title_from_text(text);
|
||||
assert_eq!(title, "Untitled Session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_from_text_whitespace_only() {
|
||||
let text = " \n\t ";
|
||||
let title = derive_title_from_text(text);
|
||||
assert_eq!(title, "Untitled Session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_from_text_with_control_chars() {
|
||||
let text = "Hello\x00world\x01test\x02";
|
||||
let title = derive_title_from_text(text);
|
||||
// Control chars should be replaced with spaces
|
||||
assert!(!title.chars().any(|c| c.is_control() && c != ' '));
|
||||
assert_eq!(title, "Hello world test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_exact_max_length() {
|
||||
// Create a string of exactly MAX_TITLE_LENGTH characters
|
||||
let text = "a".repeat(MAX_TITLE_LENGTH);
|
||||
let title = derive_title_from_text(&text);
|
||||
assert_eq!(title.len(), MAX_TITLE_LENGTH);
|
||||
assert_eq!(title, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_title_one_over_max_length() {
|
||||
// Create a string just over MAX_TITLE_LENGTH
|
||||
let text = "a".repeat(MAX_TITLE_LENGTH + 1);
|
||||
let title = derive_title_from_text(&text);
|
||||
assert_eq!(title.len(), MAX_TITLE_LENGTH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
//! HTTP transport for ACP
|
||||
//!
|
||||
//! This module provides a transport implementation that communicates with ACP agents
|
||||
//! via HTTP POST for requests and Server-Sent Events (SSE) for notifications.
|
||||
//!
|
||||
//! # Protocol
|
||||
//!
|
||||
//! - **Requests**: HTTP POST to `/jsonrpc` endpoint with JSON-RPC payload
|
||||
//! - **Notifications**: SSE stream from `/events` endpoint for real-time updates
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The transport maintains two connections:
|
||||
//! 1. HTTP client for sending requests (reqwest::Client)
|
||||
//! 2. SSE client for receiving notifications (eventsource stream)
|
||||
//!
|
||||
//! Responses and notifications are multiplexed into a single receive channel
|
||||
//! using a background task that routes SSE events based on their ID.
|
||||
//!
|
||||
//! # Lifecycle
|
||||
//!
|
||||
//! 1. Create HttpTransport with base URL
|
||||
//! 2. Call `connect()` to establish SSE connection
|
||||
//! 3. Use `send()` for requests, `recv()` for responses and notifications
|
||||
//! 4. Call `close()` to shut down connections
|
||||
|
||||
use super::{AcpTransport, TransportResult};
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// HTTP transport implementation
|
||||
///
|
||||
/// Communicates with ACP agents via HTTP POST (requests) and SSE (notifications).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use dirigent_core::connectors::acp::transport::{AcpTransport, HttpTransport};
|
||||
/// use serde_json::json;
|
||||
///
|
||||
/// # async fn example() -> anyhow::Result<()> {
|
||||
/// // Connect to ACP agent HTTP server
|
||||
/// let mut transport = HttpTransport::new("http://localhost:8080");
|
||||
///
|
||||
/// // Connect (establishes SSE stream)
|
||||
/// transport.connect().await?;
|
||||
///
|
||||
/// // Send initialize request
|
||||
/// transport.send(json!({
|
||||
/// "jsonrpc": "2.0",
|
||||
/// "method": "initialize",
|
||||
/// "id": 1,
|
||||
/// "params": {
|
||||
/// "protocolVersion": 1,
|
||||
/// "clientCapabilities": {}
|
||||
/// }
|
||||
/// })).await?;
|
||||
///
|
||||
/// // Receive response
|
||||
/// if let Some(response) = transport.recv().await? {
|
||||
/// println!("Initialized: {:?}", response);
|
||||
/// }
|
||||
///
|
||||
/// // Clean up
|
||||
/// transport.close().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub struct HttpTransport {
|
||||
/// Base URL of the ACP server (e.g., "http://localhost:8080")
|
||||
base_url: String,
|
||||
|
||||
/// Client ID for SSE subscription and RPC identification
|
||||
///
|
||||
/// This UUID is generated when connecting to the ACP server and is used:
|
||||
/// - As query parameter for SSE endpoint: `/events?client_id=...`
|
||||
/// - In RPC requests to identify this client (future: via X-Client-Id header)
|
||||
client_id: String,
|
||||
|
||||
/// Request timeout
|
||||
timeout: Duration,
|
||||
|
||||
/// HTTP client for sending requests
|
||||
client: Client,
|
||||
|
||||
/// Channel for receiving messages (responses + notifications)
|
||||
///
|
||||
/// This channel is fed by the SSE background task and HTTP responses.
|
||||
/// The recv() method reads from this channel.
|
||||
rx: Arc<Mutex<Option<mpsc::Receiver<Value>>>>,
|
||||
|
||||
/// Sender for the receive channel (used internally)
|
||||
tx: Arc<Mutex<Option<mpsc::Sender<Value>>>>,
|
||||
|
||||
/// Join handle for the SSE background task
|
||||
sse_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
|
||||
/// Flag to indicate if the transport is connected
|
||||
connected: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl HttpTransport {
|
||||
/// Create a new HttpTransport
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_url` - Base URL of the ACP server (e.g., "http://localhost:8080")
|
||||
/// * `client_id` - Client ID for SSE subscription (UUID string)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new HttpTransport that is not yet connected. Call `connect()` to
|
||||
/// establish the SSE connection.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use dirigent_core::connectors::acp::transport::HttpTransport;
|
||||
///
|
||||
/// let client_id = uuid::Uuid::new_v4().to_string();
|
||||
/// let transport = HttpTransport::new("http://localhost:8080", client_id);
|
||||
/// ```
|
||||
pub fn new(base_url: impl Into<String>, client_id: impl Into<String>) -> Self {
|
||||
let base_url = base_url.into();
|
||||
let client_id = client_id.into();
|
||||
|
||||
info!(
|
||||
base_url = %base_url,
|
||||
client_id = %client_id,
|
||||
"Creating HTTP transport"
|
||||
);
|
||||
|
||||
let (tx, rx) = mpsc::channel(1000); // Buffer up to 1000 messages
|
||||
|
||||
Self {
|
||||
base_url,
|
||||
client_id,
|
||||
timeout: Duration::from_secs(30), // Default 30 seconds
|
||||
client: Client::new(),
|
||||
rx: Arc::new(Mutex::new(Some(rx))),
|
||||
tx: Arc::new(Mutex::new(Some(tx))),
|
||||
sse_task: Arc::new(Mutex::new(None)),
|
||||
connected: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the request timeout
|
||||
///
|
||||
/// Must be called before `connect()`.
|
||||
pub fn set_timeout(&mut self, timeout: Duration) {
|
||||
self.timeout = timeout;
|
||||
}
|
||||
|
||||
/// Start the SSE background task
|
||||
///
|
||||
/// This task connects to the `/events` endpoint and forwards all SSE events
|
||||
/// to the receive channel.
|
||||
async fn start_sse_task(&self) -> TransportResult<()> {
|
||||
let url = format!("{}/events?client_id={}", self.base_url, self.client_id);
|
||||
let tx = self
|
||||
.tx
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or("Transport already closed")?;
|
||||
|
||||
info!(url = %url, client_id = %self.client_id, "Starting SSE subscription");
|
||||
|
||||
// Create SSE client using reqwest
|
||||
let client = self.client.clone();
|
||||
let sse_url = url.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
debug!("SSE task started");
|
||||
|
||||
// Connect to SSE endpoint
|
||||
let response = match client.get(&sse_url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to connect to SSE endpoint");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
error!(status = %response.status(), "SSE endpoint returned error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read SSE stream using eventsource-client
|
||||
use eventsource_client::{Client as EventSourceClient, ClientBuilder, SSE};
|
||||
|
||||
let sse_client = match ClientBuilder::for_url(&sse_url) {
|
||||
Ok(c) => c.build(),
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to create SSE client");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut stream = sse_client.stream();
|
||||
|
||||
// Process SSE events
|
||||
while let Some(event_result) = stream.next().await {
|
||||
match event_result {
|
||||
Ok(SSE::Connected(_)) => {
|
||||
info!("SSE stream connected");
|
||||
}
|
||||
Ok(SSE::Event(event)) => {
|
||||
debug!(event = ?event, "Received SSE event");
|
||||
|
||||
// Parse the event data as JSON
|
||||
match serde_json::from_str::<Value>(&event.data) {
|
||||
Ok(message) => {
|
||||
let masked_message = dirigent_protocol::log_utils::format_for_log(&message);
|
||||
debug!(message = %masked_message, "Parsed SSE message");
|
||||
|
||||
// Send to receive channel
|
||||
if let Err(e) = tx.send(message).await {
|
||||
warn!(error = %e, "Failed to forward SSE message (receiver dropped)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, data = %event.data, "Failed to parse SSE event data");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(SSE::Comment(comment)) => {
|
||||
debug!(comment = %comment, "Received SSE comment");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "SSE stream error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("SSE task ended");
|
||||
});
|
||||
|
||||
// Store the task handle
|
||||
*self.sse_task.lock().await = Some(handle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AcpTransport for HttpTransport {
|
||||
async fn connect(&mut self) -> TransportResult<()> {
|
||||
let mut connected = self.connected.lock().await;
|
||||
|
||||
if *connected {
|
||||
return Err("Transport already connected".into());
|
||||
}
|
||||
|
||||
info!(base_url = %self.base_url, "Connecting HTTP transport");
|
||||
|
||||
// Start SSE background task
|
||||
self.start_sse_task().await?;
|
||||
|
||||
*connected = true;
|
||||
|
||||
info!("HTTP transport connected");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send(&mut self, message: Value) -> TransportResult<()> {
|
||||
let connected = self.connected.lock().await;
|
||||
if !*connected {
|
||||
return Err("Transport not connected".into());
|
||||
}
|
||||
drop(connected); // Release lock
|
||||
|
||||
// Send HTTP POST to /jsonrpc
|
||||
let url = format!("{}/jsonrpc", self.base_url);
|
||||
|
||||
let masked_message = dirigent_protocol::log_utils::format_for_log(&message);
|
||||
debug!(
|
||||
url = %url,
|
||||
client_id = %self.client_id,
|
||||
message = %masked_message,
|
||||
"Sending HTTP request with X-Client-Id header"
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Client-ID", &self.client_id)
|
||||
.json(&message)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send HTTP request: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<failed to read body>".to_string());
|
||||
return Err(format!("HTTP request failed with status {}: {}", status, error_body).into());
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
let response_json: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
|
||||
let masked_response = dirigent_protocol::log_utils::format_for_log(&response_json);
|
||||
debug!(response = %masked_response, "Received HTTP response");
|
||||
|
||||
// Forward the response to the receive channel
|
||||
let tx = self
|
||||
.tx
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or("Transport closed")?;
|
||||
tx.send(response_json)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to forward response: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> TransportResult<Option<Value>> {
|
||||
let mut rx_lock = self.rx.lock().await;
|
||||
let rx = rx_lock
|
||||
.as_mut()
|
||||
.ok_or("Transport not connected (no receiver)")?;
|
||||
|
||||
// Receive from the channel (blocks until message available)
|
||||
match rx.recv().await {
|
||||
Some(message) => {
|
||||
let masked_message = dirigent_protocol::log_utils::format_for_log(&message);
|
||||
debug!(message = %masked_message, "Received message");
|
||||
Ok(Some(message))
|
||||
}
|
||||
None => {
|
||||
// Channel closed (SSE task ended)
|
||||
debug!("Receive channel closed");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> TransportResult<()> {
|
||||
info!("Closing HTTP transport");
|
||||
|
||||
// Mark as disconnected
|
||||
*self.connected.lock().await = false;
|
||||
|
||||
// Drop the sender to close the channel
|
||||
*self.tx.lock().await = None;
|
||||
|
||||
// Cancel the SSE task
|
||||
let mut sse_task_lock = self.sse_task.lock().await;
|
||||
if let Some(handle) = sse_task_lock.take() {
|
||||
debug!("Aborting SSE task");
|
||||
handle.abort();
|
||||
|
||||
// Wait for task to finish (with timeout)
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(2), handle).await {
|
||||
Ok(Ok(())) => {
|
||||
info!("SSE task finished gracefully");
|
||||
}
|
||||
Ok(Err(e)) if e.is_cancelled() => {
|
||||
info!("SSE task was cancelled");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(error = %e, "SSE task panicked");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("SSE task did not finish within timeout");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the receiver
|
||||
*self.rx.lock().await = None;
|
||||
|
||||
info!("HTTP transport closed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use axum::response::{sse::Event, Sse};
|
||||
use futures::stream;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Helper to find a free port for the test server
|
||||
async fn find_free_port() -> u16 {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("Failed to bind to random port");
|
||||
listener
|
||||
.local_addr()
|
||||
.expect("Failed to get local addr")
|
||||
.port()
|
||||
}
|
||||
|
||||
/// Helper to start a mock ACP HTTP server for testing
|
||||
async fn start_mock_server() -> (String, tokio::task::JoinHandle<()>) {
|
||||
|
||||
let port = find_free_port().await;
|
||||
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
|
||||
// Create router
|
||||
let app = Router::new()
|
||||
.route("/jsonrpc", post(handle_jsonrpc))
|
||||
.route("/events", get(handle_sse));
|
||||
|
||||
// Spawn server
|
||||
let handle = tokio::spawn(async move {
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
// Wait a bit for server to start
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
(format!("http://127.0.0.1:{}", port), handle)
|
||||
}
|
||||
|
||||
async fn handle_jsonrpc(Json(request): Json<Value>) -> Json<Value> {
|
||||
// Echo back a successful response
|
||||
let id = request.get("id").cloned().unwrap_or(json!(null));
|
||||
let method = request
|
||||
.get("method")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let result = match method {
|
||||
"initialize" => json!({
|
||||
"protocolVersion": 1,
|
||||
"agentCapabilities": {}
|
||||
}),
|
||||
"session/new" => json!({
|
||||
"sessionId": "test-session-123"
|
||||
}),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_sse() -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
|
||||
// Send a few test events
|
||||
let stream = stream::iter(vec![
|
||||
Ok(Event::default().data(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"update": {
|
||||
"type": "messageStarted"
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)),
|
||||
Ok(Event::default().data(
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"update": {
|
||||
"type": "messageCompleted"
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)),
|
||||
]);
|
||||
|
||||
Sse::new(stream)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_transport_basic_flow() {
|
||||
let (base_url, _server_handle) = start_mock_server().await;
|
||||
|
||||
// Create transport
|
||||
let mut transport = HttpTransport::new(&base_url, "test-client");
|
||||
|
||||
// Connect
|
||||
transport.connect().await.expect("Failed to connect");
|
||||
|
||||
// Test 1: Initialize
|
||||
let init_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"id": 1,
|
||||
"params": {
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": {}
|
||||
}
|
||||
});
|
||||
|
||||
transport
|
||||
.send(init_request)
|
||||
.await
|
||||
.expect("Failed to send init request");
|
||||
|
||||
let init_response = transport
|
||||
.recv()
|
||||
.await
|
||||
.expect("Failed to receive init response")
|
||||
.expect("Got None instead of response");
|
||||
|
||||
assert_eq!(init_response["jsonrpc"], "2.0");
|
||||
assert_eq!(init_response["id"], 1);
|
||||
assert!(init_response["result"]["protocolVersion"].is_number());
|
||||
|
||||
// Test 2: Create session
|
||||
let session_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/new",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"cwd": ".",
|
||||
"mcpServers": []
|
||||
}
|
||||
});
|
||||
|
||||
transport
|
||||
.send(session_request)
|
||||
.await
|
||||
.expect("Failed to send session request");
|
||||
|
||||
let session_response = transport
|
||||
.recv()
|
||||
.await
|
||||
.expect("Failed to receive session response")
|
||||
.expect("Got None instead of response");
|
||||
|
||||
assert_eq!(session_response["jsonrpc"], "2.0");
|
||||
assert_eq!(session_response["id"], 2);
|
||||
|
||||
// Test 3: Receive SSE notifications
|
||||
// The mock server sends 2 notifications
|
||||
let notif1 = transport
|
||||
.recv()
|
||||
.await
|
||||
.expect("Failed to receive notification")
|
||||
.expect("Got None instead of notification");
|
||||
assert_eq!(notif1["method"], "session/update");
|
||||
|
||||
let notif2 = transport
|
||||
.recv()
|
||||
.await
|
||||
.expect("Failed to receive notification")
|
||||
.expect("Got None instead of notification");
|
||||
assert_eq!(notif2["method"], "session/update");
|
||||
|
||||
// Close
|
||||
transport.close().await.expect("Failed to close");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_transport_sse_notifications() {
|
||||
let (base_url, _server_handle) = start_mock_server().await;
|
||||
|
||||
let mut transport = HttpTransport::new(&base_url, "test-client");
|
||||
transport.connect().await.expect("Failed to connect");
|
||||
|
||||
// Receive SSE notifications without sending any requests
|
||||
let notif1 = transport
|
||||
.recv()
|
||||
.await
|
||||
.expect("Failed to receive notification")
|
||||
.expect("Got None instead of notification");
|
||||
assert_eq!(notif1["method"], "session/update");
|
||||
|
||||
transport.close().await.expect("Failed to close");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_transport_concurrent_requests() {
|
||||
let (base_url, _server_handle) = start_mock_server().await;
|
||||
|
||||
let mut transport = HttpTransport::new(&base_url, "test-client");
|
||||
transport.connect().await.expect("Failed to connect");
|
||||
|
||||
// Send multiple requests concurrently
|
||||
let transport = Arc::new(Mutex::new(transport));
|
||||
|
||||
let mut handles = vec![];
|
||||
for i in 0..5 {
|
||||
let t = Arc::clone(&transport);
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut transport = t.lock().await;
|
||||
transport
|
||||
.send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/new",
|
||||
"id": i + 10,
|
||||
"params": {
|
||||
"cwd": ".",
|
||||
"mcpServers": []
|
||||
}
|
||||
}))
|
||||
.await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// All sends should succeed
|
||||
for handle in handles {
|
||||
handle
|
||||
.await
|
||||
.expect("Task panicked")
|
||||
.expect("Send failed");
|
||||
}
|
||||
|
||||
// Close
|
||||
let mut transport = transport.lock().await;
|
||||
transport.close().await.expect("Failed to close");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_transport_error_response() {
|
||||
// This test would require a mock server that returns errors
|
||||
// For now, we'll skip it as the basic mock server always succeeds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! ACP Transport Layer
|
||||
//!
|
||||
//! This module provides transport abstractions for the Agent-Client Protocol (ACP).
|
||||
//! Transports handle the low-level communication with ACP agents via different
|
||||
//! mechanisms (stdio, HTTP/SSE, WebSocket).
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The `AcpTransport` trait defines a standard interface for sending and receiving
|
||||
//! JSON-RPC messages. Concrete implementations handle the protocol-specific details:
|
||||
//!
|
||||
//! - **StdioTransport**: Line-delimited JSON over stdin/stdout (for process spawning)
|
||||
//! - **HttpTransport**: HTTP POST for requests + SSE for notifications
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use dirigent_core::connectors::acp::transport::{AcpTransport, StdioTransport};
|
||||
//! use serde_json::json;
|
||||
//!
|
||||
//! # async fn example() -> anyhow::Result<()> {
|
||||
//! // Create and connect a transport
|
||||
//! let mut transport = StdioTransport::new("dirigate", &["serve", "--stdio"]);
|
||||
//! transport.connect().await?;
|
||||
//!
|
||||
//! // Send a request
|
||||
//! let request = json!({
|
||||
//! "jsonrpc": "2.0",
|
||||
//! "method": "initialize",
|
||||
//! "id": 1,
|
||||
//! "params": {
|
||||
//! "protocolVersion": 1,
|
||||
//! "clientCapabilities": {}
|
||||
//! }
|
||||
//! });
|
||||
//! transport.send(request).await?;
|
||||
//!
|
||||
//! // Receive response
|
||||
//! if let Some(response) = transport.recv().await? {
|
||||
//! println!("Response: {:?}", response);
|
||||
//! }
|
||||
//!
|
||||
//! // Clean up
|
||||
//! transport.close().await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::error::Error;
|
||||
|
||||
pub mod http;
|
||||
pub mod stdio;
|
||||
|
||||
pub use http::HttpTransport;
|
||||
pub use stdio::CrashContext;
|
||||
pub use stdio::StdioTransport;
|
||||
|
||||
/// Result type for transport operations
|
||||
pub type TransportResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// Transport abstraction for ACP communication
|
||||
///
|
||||
/// This trait defines the interface for sending and receiving JSON-RPC messages
|
||||
/// over various transport mechanisms. Implementations must be thread-safe (Send + Sync)
|
||||
/// to allow concurrent operations.
|
||||
///
|
||||
/// # Object Safety
|
||||
///
|
||||
/// This trait is object-safe and can be used as `Box<dyn AcpTransport>` for
|
||||
/// dynamic dispatch when the concrete transport type is not known at compile time.
|
||||
///
|
||||
/// # Message Format
|
||||
///
|
||||
/// All messages are JSON-RPC 2.0 format, represented as `serde_json::Value`.
|
||||
/// The transport layer does not interpret message semantics - it only handles
|
||||
/// serialization and transmission.
|
||||
///
|
||||
/// # Lifecycle
|
||||
///
|
||||
/// 1. Create the transport instance with configuration
|
||||
/// 2. Call `connect()` to establish the connection
|
||||
/// 3. Use `send()` and `recv()` for bidirectional communication
|
||||
/// 4. Call `close()` to clean up resources gracefully
|
||||
#[async_trait]
|
||||
pub trait AcpTransport: Send + Sync {
|
||||
/// Establish the connection to the ACP agent
|
||||
///
|
||||
/// This method must be called before any `send()` or `recv()` operations.
|
||||
/// For stdio transports, this spawns the process. For network transports,
|
||||
/// this establishes the TCP/HTTP connection.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The connection cannot be established (network unreachable, process failed to start)
|
||||
/// - The transport is already connected
|
||||
/// - Required resources are unavailable
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use dirigent_core::connectors::acp::transport::{AcpTransport, StdioTransport};
|
||||
/// # async fn example() -> anyhow::Result<()> {
|
||||
/// let mut transport = StdioTransport::new("my-agent", &[]);
|
||||
/// transport.connect().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
async fn connect(&mut self) -> TransportResult<()>;
|
||||
|
||||
/// Send a JSON-RPC message to the agent
|
||||
///
|
||||
/// The message is serialized and transmitted according to the transport's
|
||||
/// protocol (line-delimited JSON for stdio, HTTP POST for HTTP transport).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - JSON-RPC 2.0 message (request or response)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The transport is not connected
|
||||
/// - The message cannot be serialized
|
||||
/// - The underlying I/O operation fails
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use dirigent_core::connectors::acp::transport::{AcpTransport, StdioTransport};
|
||||
/// # use serde_json::json;
|
||||
/// # async fn example(mut transport: StdioTransport) -> anyhow::Result<()> {
|
||||
/// let request = json!({
|
||||
/// "jsonrpc": "2.0",
|
||||
/// "method": "session/new",
|
||||
/// "id": 1,
|
||||
/// "params": {}
|
||||
/// });
|
||||
/// transport.send(request).await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
async fn send(&mut self, message: Value) -> TransportResult<()>;
|
||||
|
||||
/// Receive a JSON-RPC message from the agent
|
||||
///
|
||||
/// This method blocks until a message is available or the connection is closed.
|
||||
/// Returns `None` if the connection is closed gracefully (EOF on stdio, SSE
|
||||
/// stream ended, etc.).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(Some(message))` - A message was received
|
||||
/// - `Ok(None)` - The connection was closed gracefully (no more messages)
|
||||
/// - `Err(e)` - An error occurred during receive
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The transport is not connected
|
||||
/// - The received data cannot be parsed as JSON
|
||||
/// - The underlying I/O operation fails
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use dirigent_core::connectors::acp::transport::{AcpTransport, StdioTransport};
|
||||
/// # async fn example(mut transport: StdioTransport) -> anyhow::Result<()> {
|
||||
/// while let Some(message) = transport.recv().await? {
|
||||
/// println!("Received: {:?}", message);
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
async fn recv(&mut self) -> TransportResult<Option<Value>>;
|
||||
|
||||
/// Close the connection gracefully
|
||||
///
|
||||
/// This method cleans up resources and closes the connection. After calling
|
||||
/// `close()`, no further `send()` or `recv()` operations are allowed.
|
||||
///
|
||||
/// For stdio transports, this terminates the child process. For network
|
||||
/// transports, this closes the socket/connection.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the cleanup operation fails. This is typically
|
||||
/// non-fatal and can be ignored in most cases.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use dirigent_core::connectors::acp::transport::{AcpTransport, StdioTransport};
|
||||
/// # async fn example(mut transport: StdioTransport) -> anyhow::Result<()> {
|
||||
/// // ... use transport ...
|
||||
/// transport.close().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
async fn close(&mut self) -> TransportResult<()>;
|
||||
|
||||
/// Get crash context if the transport detected a child process crash.
|
||||
///
|
||||
/// Only meaningful for transports that manage child processes (e.g., StdioTransport).
|
||||
/// Returns None by default.
|
||||
async fn get_crash_context(&self) -> Option<CrashContext> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the process ID, if applicable (e.g., for stdio transports that spawn a child process).
|
||||
/// Returns None for transports that don't manage OS processes.
|
||||
async fn pid(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
//! Connector fingerprint computation
|
||||
//!
|
||||
//! Computes deterministic fingerprints for connectors based on their kind and
|
||||
//! connection parameters. Fingerprints are used by the archivist to re-associate
|
||||
//! archived data with connectors across restarts, even when connector IDs change.
|
||||
//!
|
||||
//! # Fingerprint Format
|
||||
//!
|
||||
//! Each connector kind produces fingerprints in a specific format:
|
||||
//! - ACP stdio: `acp/stdio:<resolved_command_path>`
|
||||
//! - ACP HTTP: `acp/http:<base_url>`
|
||||
//! - OpenCode: `opencode/http:<base_url>`
|
||||
//! - Gateway: `gateway:<title>`
|
||||
//! - Mock / Acceptor: no fingerprint (returns `None`)
|
||||
|
||||
use crate::types::ConnectorKind;
|
||||
|
||||
/// Compute a deterministic fingerprint for a connector based on its kind and connection params.
|
||||
///
|
||||
/// The fingerprint captures the essential identity of a connector -- the parameters that
|
||||
/// make it "the same connector" across restarts. For example, an ACP stdio connector
|
||||
/// pointing to `/usr/bin/claude` will always produce the same fingerprint regardless
|
||||
/// of what connector ID is assigned.
|
||||
///
|
||||
/// Returns `None` for Mock and Acceptor connectors, which do not have meaningful
|
||||
/// persistent identity.
|
||||
pub fn compute_fingerprint(kind: &ConnectorKind, params: &serde_json::Value) -> Option<String> {
|
||||
match kind {
|
||||
ConnectorKind::Acp => compute_acp_fingerprint(params),
|
||||
ConnectorKind::OpenCode => compute_opencode_fingerprint(params),
|
||||
ConnectorKind::Gateway => compute_gateway_fingerprint(params),
|
||||
ConnectorKind::Mock | ConnectorKind::Acceptor => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_acp_fingerprint(params: &serde_json::Value) -> Option<String> {
|
||||
let transport = params.get("transport")?;
|
||||
let transport_type = transport.get("type")?.as_str()?;
|
||||
match transport_type {
|
||||
"stdio" => {
|
||||
let command = transport.get("command")?.as_str()?;
|
||||
let resolved = resolve_command_path(command);
|
||||
Some(format!("acp/stdio:{}", resolved))
|
||||
}
|
||||
"http" => {
|
||||
let base_url = transport.get("base_url")?.as_str()?;
|
||||
Some(format!("acp/http:{}", base_url))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_opencode_fingerprint(params: &serde_json::Value) -> Option<String> {
|
||||
let base_url = params.get("base_url")?.as_str()?;
|
||||
Some(format!("opencode/http:{}", base_url))
|
||||
}
|
||||
|
||||
fn compute_gateway_fingerprint(params: &serde_json::Value) -> Option<String> {
|
||||
let title = params
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Gateway");
|
||||
Some(format!("gateway:{}", title))
|
||||
}
|
||||
|
||||
/// Resolve a command name to its absolute path.
|
||||
///
|
||||
/// If the command is already an absolute path, it is returned as-is.
|
||||
/// Otherwise, uses platform-specific lookup (`which` on Unix, `where` on Windows)
|
||||
/// to find the full path. Falls back to the raw command string if resolution fails.
|
||||
fn resolve_command_path(command: &str) -> String {
|
||||
let path = std::path::Path::new(command);
|
||||
if path.is_absolute() {
|
||||
return command.to_string();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let result = std::process::Command::new("which").arg(command).output();
|
||||
|
||||
#[cfg(windows)]
|
||||
let result = std::process::Command::new("where").arg(command).output();
|
||||
|
||||
match result {
|
||||
Ok(output) if output.status.success() => String::from_utf8(output.stdout)
|
||||
.ok()
|
||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
|
||||
.unwrap_or_else(|| command.to_string()),
|
||||
_ => command.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_acp_stdio_fingerprint() {
|
||||
let params = serde_json::json!({
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/bin/claude",
|
||||
"args": ["--acp"]
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
compute_fingerprint(&ConnectorKind::Acp, ¶ms),
|
||||
Some("acp/stdio:/usr/bin/claude".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_http_fingerprint() {
|
||||
let params = serde_json::json!({
|
||||
"transport": {
|
||||
"type": "http",
|
||||
"base_url": "http://localhost:3000"
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
compute_fingerprint(&ConnectorKind::Acp, ¶ms),
|
||||
Some("acp/http:http://localhost:3000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_opencode_fingerprint() {
|
||||
let params = serde_json::json!({"base_url": "http://localhost:12225"});
|
||||
assert_eq!(
|
||||
compute_fingerprint(&ConnectorKind::OpenCode, ¶ms),
|
||||
Some("opencode/http:http://localhost:12225".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gateway_fingerprint_with_title() {
|
||||
let params = serde_json::json!({"title": "My Gateway"});
|
||||
assert_eq!(
|
||||
compute_fingerprint(&ConnectorKind::Gateway, ¶ms),
|
||||
Some("gateway:My Gateway".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gateway_fingerprint_default_title() {
|
||||
let params = serde_json::json!({});
|
||||
assert_eq!(
|
||||
compute_fingerprint(&ConnectorKind::Gateway, ¶ms),
|
||||
Some("gateway:Gateway".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_no_fingerprint() {
|
||||
assert_eq!(
|
||||
compute_fingerprint(&ConnectorKind::Mock, &serde_json::json!({})),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acceptor_no_fingerprint() {
|
||||
assert_eq!(
|
||||
compute_fingerprint(&ConnectorKind::Acceptor, &serde_json::json!({})),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_unknown_transport_type() {
|
||||
let params = serde_json::json!({
|
||||
"transport": {
|
||||
"type": "websocket",
|
||||
"url": "ws://localhost:8080"
|
||||
}
|
||||
});
|
||||
assert_eq!(compute_fingerprint(&ConnectorKind::Acp, ¶ms), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_acp_missing_transport() {
|
||||
let params = serde_json::json!({"base_url": "http://localhost:3000"});
|
||||
assert_eq!(compute_fingerprint(&ConnectorKind::Acp, ¶ms), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_opencode_missing_base_url() {
|
||||
let params = serde_json::json!({"title": "My OpenCode"});
|
||||
assert_eq!(compute_fingerprint(&ConnectorKind::OpenCode, ¶ms), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_command_path_absolute() {
|
||||
// Absolute paths should be returned as-is
|
||||
#[cfg(unix)]
|
||||
assert_eq!(resolve_command_path("/usr/bin/claude"), "/usr/bin/claude");
|
||||
|
||||
#[cfg(windows)]
|
||||
assert_eq!(
|
||||
resolve_command_path("C:\\Program Files\\claude.exe"),
|
||||
"C:\\Program Files\\claude.exe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_command_path_nonexistent() {
|
||||
// A command that definitely doesn't exist should fall back to raw string
|
||||
let result = resolve_command_path("definitely_not_a_real_command_12345");
|
||||
assert_eq!(result, "definitely_not_a_real_command_12345");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
//! Command parsing and execution for the Gateway connector
|
||||
//!
|
||||
//! This module handles the built-in commands that can be invoked in
|
||||
//! Gateway connector sessions using the `/command` syntax.
|
||||
|
||||
use super::{ConnectorListCallback, ConnectorSummaryInfo, GatewaySession, SessionTransferCallback};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Information about a connector for transfer display
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConnectorTransferInfo {
|
||||
pub kind: String, // "ACP", "OpenCode", "Gateway"
|
||||
pub title: String,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// Commands that can be executed in a Gateway session
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Command {
|
||||
/// Enable or disable echo mode
|
||||
Echo(bool),
|
||||
|
||||
/// List all available connectors
|
||||
ListConnectors,
|
||||
|
||||
/// Select a specific connector to transfer the session to
|
||||
/// Tuple: (connector_id, optional_session_id)
|
||||
SelectConnector(String, Option<String>),
|
||||
|
||||
/// Shortcut to transfer to a Claude connector
|
||||
Claude,
|
||||
|
||||
/// Show help for available commands
|
||||
Help,
|
||||
}
|
||||
|
||||
/// Result of executing a command
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CommandResult {
|
||||
/// A message to display to the user
|
||||
Message(String),
|
||||
|
||||
/// An error message
|
||||
Error(String),
|
||||
|
||||
/// Session was transferred to another connector
|
||||
SessionTransferred {
|
||||
to_connector: String,
|
||||
/// Optional confirmation message
|
||||
message: Option<String>,
|
||||
/// Information about the connector for rich display
|
||||
connector_info: Option<ConnectorTransferInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse a command from message text
|
||||
///
|
||||
/// Commands must start with `/` and may have arguments.
|
||||
/// Returns None if the message is not a command.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use dirigent_core::connectors::gateway::commands::parse_command;
|
||||
///
|
||||
/// assert!(parse_command("/help").is_some());
|
||||
/// assert!(parse_command("/echo on").is_some());
|
||||
/// assert!(parse_command("hello").is_none());
|
||||
/// ```
|
||||
pub fn parse_command(text: &str) -> Option<Command> {
|
||||
let trimmed = text.trim();
|
||||
|
||||
// Commands must start with /
|
||||
if !trimmed.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Split into command and arguments
|
||||
let parts: Vec<&str> = trimmed[1..].splitn(2, char::is_whitespace).collect();
|
||||
let command_name = parts[0].to_lowercase();
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
|
||||
match command_name.as_str() {
|
||||
"echo" => {
|
||||
let arg = parse_arguments(args).into_iter().next().unwrap_or_default().to_lowercase();
|
||||
match arg.as_str() {
|
||||
"on" | "true" | "1" | "enable" | "yes" => Some(Command::Echo(true)),
|
||||
"off" | "false" | "0" | "disable" | "no" => Some(Command::Echo(false)),
|
||||
"" => {
|
||||
// No argument - invalid usage
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
"list-connectors" | "listconnectors" | "connectors" | "list" => {
|
||||
Some(Command::ListConnectors)
|
||||
}
|
||||
"select-connector" | "selectconnector" | "select" | "use" => {
|
||||
let args = parse_arguments(args);
|
||||
let connector_id = args.first().map(|s| sanitize_connector_id(s));
|
||||
let session_id = args.get(1).map(|s| s.trim().to_string());
|
||||
connector_id.map(|id| Command::SelectConnector(id, session_id))
|
||||
}
|
||||
"claude" => Some(Command::Claude),
|
||||
"help" | "?" => Some(Command::Help),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse command arguments, handling quoted strings
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// - `arg1 arg2` -> ["arg1", "arg2"]
|
||||
/// - `"arg with spaces" arg2` -> ["arg with spaces", "arg2"]
|
||||
fn parse_arguments(args: &str) -> Vec<&str> {
|
||||
let mut result = Vec::new();
|
||||
let mut chars = args.char_indices().peekable();
|
||||
let mut in_quotes = false;
|
||||
let mut start = 0;
|
||||
let mut current_quote = ' ';
|
||||
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if in_quotes {
|
||||
if c == current_quote {
|
||||
result.push(&args[start..i]);
|
||||
in_quotes = false;
|
||||
// Skip whitespace after closing quote
|
||||
while chars.peek().map(|(_, c)| c.is_whitespace()).unwrap_or(false) {
|
||||
chars.next();
|
||||
}
|
||||
if let Some((next_i, _)) = chars.peek() {
|
||||
start = *next_i;
|
||||
}
|
||||
}
|
||||
} else if c == '"' || c == '\'' {
|
||||
in_quotes = true;
|
||||
current_quote = c;
|
||||
start = i + 1;
|
||||
} else if c.is_whitespace() {
|
||||
if start < i {
|
||||
result.push(&args[start..i]);
|
||||
}
|
||||
// Skip additional whitespace
|
||||
while chars.peek().map(|(_, c)| c.is_whitespace()).unwrap_or(false) {
|
||||
chars.next();
|
||||
}
|
||||
if let Some((next_i, _)) = chars.peek() {
|
||||
start = *next_i;
|
||||
} else {
|
||||
start = args.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining text
|
||||
if start < args.len() && !in_quotes {
|
||||
let remaining = args[start..].trim();
|
||||
if !remaining.is_empty() {
|
||||
result.push(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Sanitize a connector ID by keeping only alphanumeric, dash, underscore
|
||||
/// Takes the first whitespace-delimited token and filters characters.
|
||||
fn sanitize_connector_id(id: &str) -> String {
|
||||
id.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Execute a command and return the result
|
||||
pub async fn execute_command(
|
||||
command: Command,
|
||||
gateway_connector_id: &str,
|
||||
session_id: &str,
|
||||
session: &mut GatewaySession,
|
||||
connector_list_callback: Option<&ConnectorListCallback>,
|
||||
session_transfer_callback: Option<&SessionTransferCallback>,
|
||||
) -> CommandResult {
|
||||
match command {
|
||||
Command::Echo(enabled) => {
|
||||
execute_echo(enabled, session)
|
||||
}
|
||||
Command::ListConnectors => {
|
||||
execute_list_connectors(connector_list_callback)
|
||||
}
|
||||
Command::SelectConnector(connector_id, target_session_id) => {
|
||||
execute_select_connector(
|
||||
gateway_connector_id,
|
||||
session_id,
|
||||
&connector_id,
|
||||
target_session_id.as_deref(),
|
||||
&session.current_mode_id,
|
||||
&session.current_model_id,
|
||||
connector_list_callback,
|
||||
session_transfer_callback,
|
||||
).await
|
||||
}
|
||||
Command::Claude => {
|
||||
execute_claude(
|
||||
gateway_connector_id,
|
||||
session_id,
|
||||
&session.current_mode_id,
|
||||
&session.current_model_id,
|
||||
connector_list_callback,
|
||||
session_transfer_callback,
|
||||
).await
|
||||
}
|
||||
Command::Help => {
|
||||
execute_help()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the /echo command
|
||||
fn execute_echo(enabled: bool, session: &mut GatewaySession) -> CommandResult {
|
||||
session.echo_enabled = enabled;
|
||||
if enabled {
|
||||
CommandResult::Message("Echo mode enabled. Your messages will be echoed back.".to_string())
|
||||
} else {
|
||||
CommandResult::Message("Echo mode disabled.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the /list-connectors command
|
||||
fn execute_list_connectors(callback: Option<&ConnectorListCallback>) -> CommandResult {
|
||||
match callback {
|
||||
Some(cb) => {
|
||||
let connectors: Vec<ConnectorSummaryInfo> = cb()
|
||||
.into_iter()
|
||||
.filter(|c| c.supports_session_transfer)
|
||||
.collect();
|
||||
if connectors.is_empty() {
|
||||
CommandResult::Message("No connectors available.".to_string())
|
||||
} else {
|
||||
let mut message = String::from("Available connectors:\n\n");
|
||||
for (i, conn) in connectors.iter().enumerate() {
|
||||
message.push_str(&format!(
|
||||
"{}. **{}** ({})\n ID: `{}`\n State: {}\n\n",
|
||||
i + 1,
|
||||
conn.title,
|
||||
conn.kind,
|
||||
conn.id,
|
||||
conn.state
|
||||
));
|
||||
}
|
||||
message.push_str("Use `/select-connector <id>` to transfer this session to a connector.");
|
||||
CommandResult::Message(message)
|
||||
}
|
||||
}
|
||||
None => {
|
||||
CommandResult::Error("Connector listing is not available.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the /select-connector command
|
||||
async fn execute_select_connector(
|
||||
gateway_connector_id: &str,
|
||||
session_id: &str,
|
||||
connector_id: &str,
|
||||
target_session_id: Option<&str>,
|
||||
current_mode_id: &str,
|
||||
current_model_id: &str,
|
||||
list_callback: Option<&ConnectorListCallback>,
|
||||
transfer_callback: Option<&SessionTransferCallback>,
|
||||
) -> CommandResult {
|
||||
use super::SessionTransferRequest;
|
||||
use super::SessionTransferResult;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
match transfer_callback {
|
||||
Some(cb) => {
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
|
||||
let request = SessionTransferRequest {
|
||||
gateway_connector_id: gateway_connector_id.to_string(),
|
||||
gateway_session_id: session_id.to_string(),
|
||||
target_connector_id: connector_id.to_string(),
|
||||
target_session_id: target_session_id.map(String::from),
|
||||
current_mode_id: current_mode_id.to_string(),
|
||||
current_model_id: current_model_id.to_string(),
|
||||
result_tx,
|
||||
};
|
||||
|
||||
// Fire the callback (non-blocking)
|
||||
cb(request);
|
||||
|
||||
// Wait for result with timeout
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
result_rx
|
||||
).await {
|
||||
Ok(Ok(SessionTransferResult::Transferred { connector_id, session_id: _, is_new, .. })) => {
|
||||
// Look up connector info if list callback is available
|
||||
let connector_info = list_callback.and_then(|list_cb| {
|
||||
let connectors = list_cb();
|
||||
connectors.into_iter()
|
||||
.find(|c| c.id == connector_id)
|
||||
.map(|summary| ConnectorTransferInfo {
|
||||
kind: summary.kind.clone(),
|
||||
title: summary.title.clone(),
|
||||
model: None, // Model comes later via SessionMetadataReceived
|
||||
})
|
||||
});
|
||||
|
||||
let mode = if is_new { "new session" } else { "loaded session" };
|
||||
CommandResult::SessionTransferred {
|
||||
to_connector: connector_id.clone(),
|
||||
message: Some(format!("Transferred to {} ({})", connector_id, mode)),
|
||||
connector_info,
|
||||
}
|
||||
}
|
||||
Ok(Ok(SessionTransferResult::Failed(reason))) => {
|
||||
CommandResult::Error(format!("Transfer failed: {}", reason))
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
CommandResult::Error("Transfer was cancelled".to_string())
|
||||
}
|
||||
Err(_) => {
|
||||
CommandResult::Error("Transfer timed out".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
CommandResult::Error("Session transfer is not available.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the /claude command
|
||||
async fn execute_claude(
|
||||
gateway_connector_id: &str,
|
||||
session_id: &str,
|
||||
current_mode_id: &str,
|
||||
current_model_id: &str,
|
||||
list_callback: Option<&ConnectorListCallback>,
|
||||
transfer_callback: Option<&SessionTransferCallback>,
|
||||
) -> CommandResult {
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
// First, find a Claude connector by agent_type, with fallback to name matching
|
||||
let claude_connector = match list_callback {
|
||||
Some(cb) => {
|
||||
let connectors = cb();
|
||||
// Priority 1: Find by agent_type = Claude
|
||||
connectors.iter().find(|c| c.agent_type == Some(ConnectorAgentType::Claude))
|
||||
.cloned()
|
||||
// Priority 2: Fallback to name matching (for backwards compatibility)
|
||||
.or_else(|| {
|
||||
connectors.into_iter().find(|c| {
|
||||
c.title.to_lowercase().contains("claude") ||
|
||||
c.id.to_lowercase().contains("claude")
|
||||
})
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match claude_connector {
|
||||
Some(connector) => {
|
||||
execute_select_connector(
|
||||
gateway_connector_id,
|
||||
session_id,
|
||||
&connector.id,
|
||||
None,
|
||||
current_mode_id,
|
||||
current_model_id,
|
||||
list_callback,
|
||||
transfer_callback,
|
||||
).await
|
||||
}
|
||||
None => {
|
||||
CommandResult::Error(
|
||||
"No Claude connector found. Use `/list-connectors` to see available connectors.".to_string()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the /help command
|
||||
fn execute_help() -> CommandResult {
|
||||
let help_text = r#"**Available Commands**
|
||||
|
||||
`/echo on|off`
|
||||
Enable or disable echo mode. When enabled, your messages will be echoed back.
|
||||
|
||||
`/list-connectors`
|
||||
Show all available connectors that you can transfer this session to.
|
||||
|
||||
`/select-connector <id>`
|
||||
Transfer this session to the specified connector. Use the connector ID from `/list-connectors`.
|
||||
|
||||
`/claude`
|
||||
Shortcut to transfer this session to a Claude connector (finds a connector with "claude" in its name).
|
||||
|
||||
`/help`
|
||||
Show this help message.
|
||||
|
||||
**Notes**
|
||||
- Commands start with `/`
|
||||
- Arguments can be quoted if they contain spaces
|
||||
- Echo mode lets you test the connection without using an external agent"#;
|
||||
|
||||
CommandResult::Message(help_text.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_help() {
|
||||
assert_eq!(parse_command("/help"), Some(Command::Help));
|
||||
assert_eq!(parse_command("/HELP"), Some(Command::Help));
|
||||
assert_eq!(parse_command("/?"), Some(Command::Help));
|
||||
assert_eq!(parse_command("/help extra args"), Some(Command::Help));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_echo() {
|
||||
assert_eq!(parse_command("/echo on"), Some(Command::Echo(true)));
|
||||
assert_eq!(parse_command("/echo off"), Some(Command::Echo(false)));
|
||||
assert_eq!(parse_command("/echo ON"), Some(Command::Echo(true)));
|
||||
assert_eq!(parse_command("/echo true"), Some(Command::Echo(true)));
|
||||
assert_eq!(parse_command("/echo false"), Some(Command::Echo(false)));
|
||||
assert_eq!(parse_command("/echo enable"), Some(Command::Echo(true)));
|
||||
assert_eq!(parse_command("/echo disable"), Some(Command::Echo(false)));
|
||||
assert_eq!(parse_command("/echo"), None); // Missing argument
|
||||
assert_eq!(parse_command("/echo invalid"), None); // Invalid argument
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_list_connectors() {
|
||||
assert_eq!(parse_command("/list-connectors"), Some(Command::ListConnectors));
|
||||
assert_eq!(parse_command("/listconnectors"), Some(Command::ListConnectors));
|
||||
assert_eq!(parse_command("/connectors"), Some(Command::ListConnectors));
|
||||
assert_eq!(parse_command("/list"), Some(Command::ListConnectors));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_select_connector() {
|
||||
assert_eq!(
|
||||
parse_command("/select-connector my-connector"),
|
||||
Some(Command::SelectConnector("my-connector".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_command("/select my-connector"),
|
||||
Some(Command::SelectConnector("my-connector".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_command("/use my-connector"),
|
||||
Some(Command::SelectConnector("my-connector".to_string(), None))
|
||||
);
|
||||
assert_eq!(parse_command("/select-connector"), None); // Missing ID
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_claude() {
|
||||
assert_eq!(parse_command("/claude"), Some(Command::Claude));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_not_a_command() {
|
||||
assert_eq!(parse_command("hello"), None);
|
||||
assert_eq!(parse_command("not a command"), None);
|
||||
assert_eq!(parse_command(""), None);
|
||||
assert_eq!(parse_command(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_unknown_command() {
|
||||
assert_eq!(parse_command("/unknown"), None);
|
||||
assert_eq!(parse_command("/foobar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_arguments_simple() {
|
||||
assert_eq!(parse_arguments("arg1 arg2"), vec!["arg1", "arg2"]);
|
||||
assert_eq!(parse_arguments("single"), vec!["single"]);
|
||||
assert_eq!(parse_arguments(""), Vec::<&str>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_arguments_quoted() {
|
||||
assert_eq!(
|
||||
parse_arguments("\"arg with spaces\" arg2"),
|
||||
vec!["arg with spaces", "arg2"]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_arguments("'single quoted' arg2"),
|
||||
vec!["single quoted", "arg2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_arguments_whitespace() {
|
||||
assert_eq!(parse_arguments(" arg1 arg2 "), vec!["arg1", "arg2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_echo_on() {
|
||||
let mut session = GatewaySession::new(
|
||||
"test".to_string(),
|
||||
"Test".to_string(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let result = execute_echo(true, &mut session);
|
||||
assert!(session.echo_enabled);
|
||||
match result {
|
||||
CommandResult::Message(msg) => assert!(msg.contains("enabled")),
|
||||
_ => panic!("Expected Message result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_echo_off() {
|
||||
let mut session = GatewaySession::new(
|
||||
"test".to_string(),
|
||||
"Test".to_string(),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
let result = execute_echo(false, &mut session);
|
||||
assert!(!session.echo_enabled);
|
||||
match result {
|
||||
CommandResult::Message(msg) => assert!(msg.contains("disabled")),
|
||||
_ => panic!("Expected Message result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_help() {
|
||||
let result = execute_help();
|
||||
match result {
|
||||
CommandResult::Message(msg) => {
|
||||
assert!(msg.contains("/echo"));
|
||||
assert!(msg.contains("/help"));
|
||||
assert!(msg.contains("/list-connectors"));
|
||||
assert!(msg.contains("/select-connector"));
|
||||
assert!(msg.contains("/claude"));
|
||||
}
|
||||
_ => panic!("Expected Message result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_list_connectors_no_callback() {
|
||||
let result = execute_list_connectors(None);
|
||||
match result {
|
||||
CommandResult::Error(msg) => assert!(msg.contains("not available")),
|
||||
_ => panic!("Expected Error result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_select_connector_no_callback() {
|
||||
let result = execute_select_connector(
|
||||
"gateway-1",
|
||||
"session-1",
|
||||
"connector-1",
|
||||
None,
|
||||
"ask",
|
||||
"default",
|
||||
None,
|
||||
None,
|
||||
).await;
|
||||
match result {
|
||||
CommandResult::Error(msg) => assert!(msg.contains("not available")),
|
||||
_ => panic!("Expected Error result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_list_connectors_with_callback() {
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create a callback that returns test connectors
|
||||
let callback: super::ConnectorListCallback = Arc::new(|| {
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
vec![
|
||||
ConnectorSummaryInfo {
|
||||
id: "opencode-1".to_string(),
|
||||
title: "OpenCode Local".to_string(),
|
||||
kind: "OpenCode".to_string(),
|
||||
state: "Ready".to_string(),
|
||||
supports_session_transfer: true,
|
||||
agent_type: None,
|
||||
},
|
||||
ConnectorSummaryInfo {
|
||||
id: "claude-acp".to_string(),
|
||||
title: "Claude (ACP)".to_string(),
|
||||
kind: "Acp".to_string(),
|
||||
state: "Ready".to_string(),
|
||||
supports_session_transfer: true,
|
||||
agent_type: Some(ConnectorAgentType::Claude),
|
||||
},
|
||||
ConnectorSummaryInfo {
|
||||
id: "gateway-1".to_string(),
|
||||
title: "Gateway".to_string(),
|
||||
kind: "Gateway".to_string(),
|
||||
state: "Ready".to_string(),
|
||||
supports_session_transfer: true,
|
||||
agent_type: None,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
let result = execute_list_connectors(Some(&callback));
|
||||
match result {
|
||||
CommandResult::Message(msg) => {
|
||||
// Verify the output contains all connectors
|
||||
assert!(msg.contains("Available connectors:"));
|
||||
assert!(msg.contains("opencode-1"));
|
||||
assert!(msg.contains("OpenCode Local"));
|
||||
assert!(msg.contains("claude-acp"));
|
||||
assert!(msg.contains("Claude (ACP)"));
|
||||
assert!(msg.contains("gateway-1"));
|
||||
assert!(msg.contains("Gateway"));
|
||||
assert!(msg.contains("/select-connector"));
|
||||
}
|
||||
_ => panic!("Expected Message result, got {:?}", result),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_list_connectors_empty() {
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create a callback that returns empty list
|
||||
let callback: super::ConnectorListCallback = Arc::new(|| Vec::new());
|
||||
|
||||
let result = execute_list_connectors(Some(&callback));
|
||||
match result {
|
||||
CommandResult::Message(msg) => {
|
||||
assert!(msg.contains("No connectors available"));
|
||||
}
|
||||
_ => panic!("Expected Message result, got {:?}", result),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_connector_id() {
|
||||
assert_eq!(sanitize_connector_id("valid-id_123"), "valid-id_123");
|
||||
assert_eq!(sanitize_connector_id("has spaces"), "has");
|
||||
assert_eq!(sanitize_connector_id("weird@#$chars"), "weirdchars");
|
||||
assert_eq!(sanitize_connector_id(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_select_connector_with_session_id() {
|
||||
assert_eq!(
|
||||
parse_command("/select-connector conn-1 session-abc"),
|
||||
Some(Command::SelectConnector("conn-1".to_string(), Some("session-abc".to_string())))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_command("/select conn-1"),
|
||||
Some(Command::SelectConnector("conn-1".to_string(), None))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_select_connector_with_connector_info() {
|
||||
use std::sync::Arc;
|
||||
use crate::connectors::gateway::{SessionTransferCallback, SessionTransferResult};
|
||||
|
||||
// Create a list callback that returns test connectors
|
||||
let list_callback: ConnectorListCallback = Arc::new(|| {
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
vec![
|
||||
ConnectorSummaryInfo {
|
||||
id: "opencode-1".to_string(),
|
||||
title: "OpenCode Local".to_string(),
|
||||
kind: "OpenCode".to_string(),
|
||||
state: "Ready".to_string(),
|
||||
supports_session_transfer: true,
|
||||
agent_type: None,
|
||||
},
|
||||
ConnectorSummaryInfo {
|
||||
id: "claude-acp".to_string(),
|
||||
title: "Claude (ACP)".to_string(),
|
||||
kind: "Acp".to_string(),
|
||||
state: "Ready".to_string(),
|
||||
supports_session_transfer: true,
|
||||
agent_type: Some(ConnectorAgentType::Claude),
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
// Create a transfer callback that simulates a successful transfer
|
||||
let transfer_callback: SessionTransferCallback = Arc::new(|request| {
|
||||
let result = SessionTransferResult::Transferred {
|
||||
connector_id: request.target_connector_id.clone(),
|
||||
session_id: "new-session-123".to_string(),
|
||||
is_new: true,
|
||||
models: None,
|
||||
modes: None,
|
||||
};
|
||||
let _ = request.result_tx.send(result);
|
||||
});
|
||||
|
||||
let result = execute_select_connector(
|
||||
"gateway-1",
|
||||
"session-1",
|
||||
"opencode-1",
|
||||
None,
|
||||
"ask",
|
||||
"default",
|
||||
Some(&list_callback),
|
||||
Some(&transfer_callback),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
CommandResult::SessionTransferred {
|
||||
to_connector,
|
||||
message,
|
||||
connector_info,
|
||||
} => {
|
||||
assert_eq!(to_connector, "opencode-1");
|
||||
assert!(message.is_some());
|
||||
|
||||
// Verify connector info is populated
|
||||
let info = connector_info.expect("connector_info should be populated");
|
||||
assert_eq!(info.kind, "OpenCode");
|
||||
assert_eq!(info.title, "OpenCode Local");
|
||||
assert_eq!(info.model, None); // Model comes later via SessionMetadataReceived
|
||||
}
|
||||
_ => panic!("Expected SessionTransferred result, got {:?}", result),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_select_connector_without_list_callback() {
|
||||
use std::sync::Arc;
|
||||
use crate::connectors::gateway::{SessionTransferCallback, SessionTransferResult};
|
||||
|
||||
// Create a transfer callback that simulates a successful transfer
|
||||
let transfer_callback: SessionTransferCallback = Arc::new(|request| {
|
||||
let result = SessionTransferResult::Transferred {
|
||||
connector_id: request.target_connector_id.clone(),
|
||||
session_id: "new-session-123".to_string(),
|
||||
is_new: true,
|
||||
models: None,
|
||||
modes: None,
|
||||
};
|
||||
let _ = request.result_tx.send(result);
|
||||
});
|
||||
|
||||
let result = execute_select_connector(
|
||||
"gateway-1",
|
||||
"session-1",
|
||||
"opencode-1",
|
||||
None,
|
||||
"ask",
|
||||
"default",
|
||||
None, // No list callback
|
||||
Some(&transfer_callback),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
CommandResult::SessionTransferred {
|
||||
to_connector,
|
||||
message,
|
||||
connector_info,
|
||||
} => {
|
||||
assert_eq!(to_connector, "opencode-1");
|
||||
assert!(message.is_some());
|
||||
|
||||
// Without list callback, connector info should be None
|
||||
assert!(connector_info.is_none(), "connector_info should be None when list_callback is not available");
|
||||
}
|
||||
_ => panic!("Expected SessionTransferred result, got {:?}", result),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Echo mode implementation for the Gateway connector
|
||||
//!
|
||||
//! This module handles the echo response generation and optional
|
||||
//! streaming simulation for realistic response behavior.
|
||||
|
||||
use crate::sharing::bus::SharingBus;
|
||||
use dirigent_protocol::types::ContentBlock;
|
||||
use dirigent_protocol::{Event, Message, MessageRole, MessageStatus, SessionUpdate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Configuration for echo behavior
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EchoConfig {
|
||||
/// Whether to simulate streaming by breaking the response into chunks
|
||||
#[serde(default)]
|
||||
pub simulate_streaming: bool,
|
||||
|
||||
/// Delay between chunks in milliseconds (when simulate_streaming is true)
|
||||
/// Default: 0 (instant)
|
||||
#[serde(default)]
|
||||
pub chunk_delay_ms: u64,
|
||||
|
||||
/// Approximate size of each chunk in characters (when simulate_streaming is true)
|
||||
/// Default: 10
|
||||
#[serde(default = "default_chunk_size")]
|
||||
pub chunk_size: usize,
|
||||
|
||||
/// Prefix to add to echoed messages (optional)
|
||||
#[serde(default)]
|
||||
pub prefix: Option<String>,
|
||||
|
||||
/// Suffix to add to echoed messages (optional)
|
||||
#[serde(default)]
|
||||
pub suffix: Option<String>,
|
||||
}
|
||||
|
||||
fn default_chunk_size() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
impl Default for EchoConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
simulate_streaming: false,
|
||||
chunk_delay_ms: 0,
|
||||
chunk_size: default_chunk_size(),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an echo response for the given input text
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - The user's message text to echo
|
||||
/// * `config` - Echo configuration for formatting
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The formatted echo response string
|
||||
pub fn generate_echo_response(text: &str, config: &EchoConfig) -> String {
|
||||
let mut response = String::new();
|
||||
|
||||
if let Some(ref prefix) = config.prefix {
|
||||
response.push_str(prefix);
|
||||
}
|
||||
|
||||
response.push_str(text);
|
||||
|
||||
if let Some(ref suffix) = config.suffix {
|
||||
response.push_str(suffix);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Stream an echo response as multiple chunks
|
||||
///
|
||||
/// This function simulates streaming behavior by breaking the response
|
||||
/// into chunks and emitting them with optional delays.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `connector_id` - The connector ID for events
|
||||
/// * `session_id` - The session ID for events
|
||||
/// * `message_id` - The message ID for the response
|
||||
/// * `response` - The full response text to stream
|
||||
/// * `config` - Echo configuration for chunk sizes and delays
|
||||
/// * `events_tx` - The broadcast sender for emitting events
|
||||
pub async fn stream_echo_response(
|
||||
connector_id: &str,
|
||||
connector_uid: Option<Uuid>,
|
||||
session_id: &str,
|
||||
message_id: &str,
|
||||
response: &str,
|
||||
config: &EchoConfig,
|
||||
events_tx: &broadcast::Sender<Event>,
|
||||
sharing_bus: &Arc<SharingBus>,
|
||||
) {
|
||||
debug!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
message_id = %message_id,
|
||||
response_len = response.len(),
|
||||
chunk_size = config.chunk_size,
|
||||
delay_ms = config.chunk_delay_ms,
|
||||
"Streaming echo response"
|
||||
);
|
||||
|
||||
// Create a placeholder message for MessageStarted
|
||||
let placeholder_message = Message {
|
||||
id: message_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
role: MessageRole::Assistant,
|
||||
content: vec![],
|
||||
created_at: chrono::Utc::now(),
|
||||
status: MessageStatus::Streaming,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
// Emit MessageStarted (bus + broadcast)
|
||||
let started_event = Event::MessageStarted {
|
||||
connector_id: connector_id.to_string(),
|
||||
message: placeholder_message,
|
||||
};
|
||||
let bus_event = dirigent_protocol::streaming::BusEvent::from_connector_event(
|
||||
started_event.clone(),
|
||||
connector_uid,
|
||||
connector_id.to_string(),
|
||||
);
|
||||
sharing_bus.publish(bus_event).await;
|
||||
let _ = events_tx.send(started_event);
|
||||
|
||||
// Break response into chunks and stream them
|
||||
let chunks = break_into_chunks(response, config.chunk_size);
|
||||
|
||||
for chunk in chunks {
|
||||
// Emit the chunk (bus + broadcast)
|
||||
let chunk_event = Event::SessionUpdate {
|
||||
connector_id: connector_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: message_id.to_string(),
|
||||
content: ContentBlock::Text { text: chunk },
|
||||
_meta: None,
|
||||
},
|
||||
};
|
||||
let bus_event = dirigent_protocol::streaming::BusEvent::from_connector_event(
|
||||
chunk_event.clone(),
|
||||
connector_uid,
|
||||
connector_id.to_string(),
|
||||
);
|
||||
sharing_bus.publish(bus_event).await;
|
||||
let _ = events_tx.send(chunk_event);
|
||||
|
||||
// Apply delay if configured
|
||||
if config.chunk_delay_ms > 0 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(config.chunk_delay_ms)).await;
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
connector_id = %connector_id,
|
||||
message_id = %message_id,
|
||||
"Finished streaming echo response"
|
||||
);
|
||||
}
|
||||
|
||||
/// Break a string into chunks of approximately the given size
|
||||
///
|
||||
/// Tries to break at word boundaries when possible.
|
||||
fn break_into_chunks(text: &str, chunk_size: usize) -> Vec<String> {
|
||||
if chunk_size == 0 || text.is_empty() {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut current_chunk = String::new();
|
||||
|
||||
for word in text.split_inclusive(char::is_whitespace) {
|
||||
if current_chunk.len() + word.len() > chunk_size && !current_chunk.is_empty() {
|
||||
chunks.push(current_chunk);
|
||||
current_chunk = String::new();
|
||||
}
|
||||
current_chunk.push_str(word);
|
||||
}
|
||||
|
||||
if !current_chunk.is_empty() {
|
||||
chunks.push(current_chunk);
|
||||
}
|
||||
|
||||
// If no chunks were created (e.g., single long word), just return the whole text
|
||||
if chunks.is_empty() {
|
||||
chunks.push(text.to_string());
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_echo_config_default() {
|
||||
let config = EchoConfig::default();
|
||||
assert!(!config.simulate_streaming);
|
||||
assert_eq!(config.chunk_delay_ms, 0);
|
||||
assert_eq!(config.chunk_size, 10);
|
||||
assert!(config.prefix.is_none());
|
||||
assert!(config.suffix.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_config_serialization() {
|
||||
let config = EchoConfig {
|
||||
simulate_streaming: true,
|
||||
chunk_delay_ms: 50,
|
||||
chunk_size: 20,
|
||||
prefix: Some("Echo: ".to_string()),
|
||||
suffix: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: EchoConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.simulate_streaming, config.simulate_streaming);
|
||||
assert_eq!(deserialized.chunk_delay_ms, config.chunk_delay_ms);
|
||||
assert_eq!(deserialized.chunk_size, config.chunk_size);
|
||||
assert_eq!(deserialized.prefix, config.prefix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_echo_response_simple() {
|
||||
let config = EchoConfig::default();
|
||||
let response = generate_echo_response("Hello, World!", &config);
|
||||
assert_eq!(response, "Hello, World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_echo_response_with_prefix() {
|
||||
let config = EchoConfig {
|
||||
prefix: Some("Echo: ".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let response = generate_echo_response("Hello", &config);
|
||||
assert_eq!(response, "Echo: Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_echo_response_with_suffix() {
|
||||
let config = EchoConfig {
|
||||
suffix: Some(" [echoed]".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let response = generate_echo_response("Hello", &config);
|
||||
assert_eq!(response, "Hello [echoed]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_echo_response_with_prefix_and_suffix() {
|
||||
let config = EchoConfig {
|
||||
prefix: Some("> ".to_string()),
|
||||
suffix: Some(" <".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let response = generate_echo_response("test", &config);
|
||||
assert_eq!(response, "> test <");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_into_chunks_simple() {
|
||||
let chunks = break_into_chunks("hello world", 5);
|
||||
assert!(!chunks.is_empty());
|
||||
let combined: String = chunks.join("");
|
||||
assert_eq!(combined, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_into_chunks_single_word() {
|
||||
let chunks = break_into_chunks("superlongword", 5);
|
||||
// Single word longer than chunk size - should still work
|
||||
assert!(!chunks.is_empty());
|
||||
let combined: String = chunks.join("");
|
||||
assert_eq!(combined, "superlongword");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_into_chunks_empty() {
|
||||
let chunks = break_into_chunks("", 5);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_into_chunks_zero_size() {
|
||||
let chunks = break_into_chunks("hello world", 0);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0], "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_into_chunks_large_size() {
|
||||
let text = "hello world";
|
||||
let chunks = break_into_chunks(text, 1000);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0], "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_into_chunks_preserves_whitespace() {
|
||||
let text = "word1 word2 word3";
|
||||
let chunks = break_into_chunks(text, 10);
|
||||
let combined: String = chunks.join("");
|
||||
assert_eq!(combined, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_break_into_chunks_multiple() {
|
||||
let text = "This is a longer piece of text that should be broken into multiple chunks.";
|
||||
let chunks = break_into_chunks(text, 15);
|
||||
assert!(chunks.len() > 1);
|
||||
let combined: String = chunks.join("");
|
||||
assert_eq!(combined, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Mode and model mapping module for Gateway connector
|
||||
//!
|
||||
//! This module translates between Gateway's standardized modes/models and agent-specific values.
|
||||
//! Gateway advertises generic modes like "simple", "dailydriver", "high" which map to agent-specific
|
||||
//! values like "haiku", "sonnet", "opus" for Claude.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The Gateway connector serves as a session entry point for incoming ACP connections. When
|
||||
//! sessions are transferred to target agents (Claude, Codex, Gemini), the Gateway's standardized
|
||||
//! mode/model identifiers must be translated to agent-specific identifiers.
|
||||
//!
|
||||
//! # Mapping Direction
|
||||
//!
|
||||
//! - **Forward mapping** (`map_mode`, `map_model`): Gateway → Agent
|
||||
//! Used when setting mode/model on a target agent after session transfer.
|
||||
//! Example: User clicks "High" in editor → send "opus" to Claude.
|
||||
//!
|
||||
//! - **Reverse mapping** (`reverse_map_mode`, `reverse_map_model`): Agent → Gateway
|
||||
//! Used in legacy mode when reporting agent state back to editor.
|
||||
//! Example: Claude reports "opus" → display "High" in editor.
|
||||
//!
|
||||
//! # Legacy vs Config Options
|
||||
//!
|
||||
//! These mappings only apply in legacy mode (without Zed's `acp-beta` flag).
|
||||
//! When using the new `config_options` system, values pass through unchanged.
|
||||
//!
|
||||
//! # Implementation
|
||||
//!
|
||||
//! All vendor-specific mappings are delegated to the vendor registry at
|
||||
//! `dirigent_core::vendors::VENDOR_REGISTRY`. Each vendor implements the
|
||||
//! `VendorInfo` trait with its specific mappings.
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use dirigent_core::connectors::gateway::mappings::{map_mode, map_model, gateway_modes, gateway_models};
|
||||
//! use dirigent_core::connectors::gateway::mappings::{reverse_map_mode, reverse_map_model};
|
||||
//! use dirigent_core::connectors::acp::config::ConnectorAgentType;
|
||||
//!
|
||||
//! // Forward mapping: Gateway mode to Claude mode
|
||||
//! let result = map_mode(gateway_modes::WRITE, ConnectorAgentType::Claude);
|
||||
//! assert_eq!(result.mapped_id, "acceptEdits");
|
||||
//! assert!(result.warning.is_none());
|
||||
//!
|
||||
//! // Forward mapping: Gateway model to Claude model
|
||||
//! let result = map_model(gateway_models::SIMPLE, ConnectorAgentType::Claude);
|
||||
//! assert_eq!(result.mapped_id, "haiku");
|
||||
//! assert!(result.warning.is_none());
|
||||
//!
|
||||
//! // Reverse mapping: Claude model to Gateway model
|
||||
//! let result = reverse_map_model("opus", ConnectorAgentType::Claude);
|
||||
//! assert_eq!(result.mapped_id, "high");
|
||||
//! assert!(result.warning.is_none());
|
||||
//! ```
|
||||
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
use crate::vendors::VENDOR_REGISTRY;
|
||||
|
||||
/// Gateway mode identifiers advertised to clients
|
||||
pub mod gateway_modes {
|
||||
/// Plan mode - agent creates execution plans but doesn't execute
|
||||
pub const PLAN: &str = "plan";
|
||||
/// Read-only mode - agent can read but not modify files
|
||||
pub const READONLY: &str = "readonly";
|
||||
/// Ask mode - agent asks before every action
|
||||
pub const ASK: &str = "ask";
|
||||
/// Write mode - agent can modify files with explicit permission
|
||||
pub const WRITE: &str = "write";
|
||||
/// YOLO mode - agent can perform any action without asking
|
||||
pub const YOLO: &str = "yolo";
|
||||
}
|
||||
|
||||
/// Gateway model identifiers advertised to clients
|
||||
pub mod gateway_models {
|
||||
/// Simple model - fast, lightweight, lower capability
|
||||
pub const SIMPLE: &str = "simple";
|
||||
/// Daily driver model - balanced performance and capability, the go-to model for everyday tasks
|
||||
pub const DAILYDRIVER: &str = "dailydriver";
|
||||
/// High model - most capable, slower, higher cost
|
||||
pub const HIGH: &str = "high";
|
||||
}
|
||||
|
||||
/// Claude-specific mode identifiers
|
||||
///
|
||||
/// Note: These are re-exported for backwards compatibility.
|
||||
/// Prefer using `dirigent_core::vendors::claude::modes` for new code.
|
||||
pub mod claude_modes {
|
||||
pub use crate::vendors::claude::modes::*;
|
||||
}
|
||||
|
||||
/// Claude-specific model identifiers
|
||||
///
|
||||
/// Note: These are re-exported for backwards compatibility.
|
||||
/// Prefer using `dirigent_core::vendors::claude::models` for new code.
|
||||
pub mod claude_models {
|
||||
pub use crate::vendors::claude::models::*;
|
||||
}
|
||||
|
||||
/// Result of a mode/model mapping operation.
|
||||
///
|
||||
/// This type is re-exported from the vendors module for backwards compatibility.
|
||||
pub use crate::vendors::MappingResult;
|
||||
|
||||
// =============================================================================
|
||||
// FORWARD MAPPING: Gateway → Agent
|
||||
// =============================================================================
|
||||
|
||||
/// Map a Gateway mode to an agent-specific mode.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `gateway_mode` - The Gateway mode identifier to map
|
||||
/// * `agent_type` - The target agent type
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `MappingResult` containing the mapped agent-specific mode and optional warning.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_core::connectors::gateway::mappings::{map_mode, gateway_modes};
|
||||
/// use dirigent_core::connectors::acp::config::ConnectorAgentType;
|
||||
///
|
||||
/// let result = map_mode(gateway_modes::WRITE, ConnectorAgentType::Claude);
|
||||
/// assert_eq!(result.mapped_id, "acceptEdits");
|
||||
/// ```
|
||||
pub fn map_mode(gateway_mode: &str, agent_type: ConnectorAgentType) -> MappingResult {
|
||||
VENDOR_REGISTRY
|
||||
.get_by_agent_type(agent_type)
|
||||
.map(|vendor| vendor.map_mode(gateway_mode))
|
||||
.unwrap_or_else(|| MappingResult::exact(gateway_mode))
|
||||
}
|
||||
|
||||
/// Map a Gateway model to an agent-specific model.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `gateway_model` - The Gateway model identifier to map
|
||||
/// * `agent_type` - The target agent type
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `MappingResult` containing the mapped agent-specific model and optional warning.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_core::connectors::gateway::mappings::{map_model, gateway_models};
|
||||
/// use dirigent_core::connectors::acp::config::ConnectorAgentType;
|
||||
///
|
||||
/// let result = map_model(gateway_models::SIMPLE, ConnectorAgentType::Claude);
|
||||
/// assert_eq!(result.mapped_id, "haiku");
|
||||
/// ```
|
||||
pub fn map_model(gateway_model: &str, agent_type: ConnectorAgentType) -> MappingResult {
|
||||
VENDOR_REGISTRY
|
||||
.get_by_agent_type(agent_type)
|
||||
.map(|vendor| vendor.map_model(gateway_model))
|
||||
.unwrap_or_else(|| MappingResult::exact(gateway_model))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// REVERSE MAPPING: Agent → Gateway
|
||||
// =============================================================================
|
||||
|
||||
/// Reverse map an agent-specific mode to a Gateway mode.
|
||||
///
|
||||
/// Used in legacy mode when reporting agent state back to the editor.
|
||||
/// The editor only understands Gateway modes (plan, readonly, ask, write, yolo),
|
||||
/// so agent-specific modes must be translated back.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `agent_mode` - The agent-specific mode identifier to map
|
||||
/// * `agent_type` - The agent type to map from
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `MappingResult` containing the mapped Gateway mode and optional warning.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_core::connectors::gateway::mappings::reverse_map_mode;
|
||||
/// use dirigent_core::connectors::acp::config::ConnectorAgentType;
|
||||
///
|
||||
/// let result = reverse_map_mode("acceptEdits", ConnectorAgentType::Claude);
|
||||
/// assert_eq!(result.mapped_id, "write");
|
||||
/// ```
|
||||
pub fn reverse_map_mode(agent_mode: &str, agent_type: ConnectorAgentType) -> MappingResult {
|
||||
VENDOR_REGISTRY
|
||||
.get_by_agent_type(agent_type)
|
||||
.map(|vendor| vendor.reverse_map_mode(agent_mode))
|
||||
.unwrap_or_else(|| MappingResult::exact(agent_mode))
|
||||
}
|
||||
|
||||
/// Reverse map an agent-specific model to a Gateway model.
|
||||
///
|
||||
/// Used in legacy mode when reporting agent state back to the editor.
|
||||
/// The editor only understands Gateway models (simple, daily, high),
|
||||
/// so agent-specific models must be translated back.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `agent_model` - The agent-specific model identifier to map
|
||||
/// * `agent_type` - The agent type to map from
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `MappingResult` containing the mapped Gateway model and optional warning.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_core::connectors::gateway::mappings::reverse_map_model;
|
||||
/// use dirigent_core::connectors::acp::config::ConnectorAgentType;
|
||||
///
|
||||
/// let result = reverse_map_model("opus", ConnectorAgentType::Claude);
|
||||
/// assert_eq!(result.mapped_id, "high");
|
||||
/// ```
|
||||
pub fn reverse_map_model(agent_model: &str, agent_type: ConnectorAgentType) -> MappingResult {
|
||||
VENDOR_REGISTRY
|
||||
.get_by_agent_type(agent_type)
|
||||
.map(|vendor| vendor.reverse_map_model(agent_model))
|
||||
.unwrap_or_else(|| MappingResult::exact(agent_model))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ==========================================================================
|
||||
// MappingResult tests
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_mapping_result_exact() {
|
||||
let result = MappingResult::exact("test");
|
||||
assert_eq!(result.mapped_id, "test");
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mapping_result_approximate() {
|
||||
let result = MappingResult::approximate("test", "warning message");
|
||||
assert_eq!(result.mapped_id, "test");
|
||||
assert_eq!(result.warning, Some("warning message".to_string()));
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Forward mapping tests: Gateway → Agent
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_map_mode_claude_exact() {
|
||||
let result = map_mode(gateway_modes::ASK, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_modes::DEFAULT);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = map_mode(gateway_modes::PLAN, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_modes::PLAN);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = map_mode(gateway_modes::WRITE, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_modes::ACCEPT_EDITS);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = map_mode(gateway_modes::YOLO, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_modes::BYPASS_PERMISSIONS);
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_mode_claude_approximate() {
|
||||
let result = map_mode(gateway_modes::READONLY, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_modes::PLAN);
|
||||
assert!(result.warning.is_some());
|
||||
assert!(result.warning.unwrap().contains("readonly"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_mode_claude_unknown() {
|
||||
let result = map_mode("unknown-mode", ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_modes::DEFAULT);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_mode_custom_passthrough() {
|
||||
let result = map_mode("custom-mode", ConnectorAgentType::Custom);
|
||||
assert_eq!(result.mapped_id, "custom-mode");
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_mode_codex_passthrough() {
|
||||
// Codex currently passes through with warning
|
||||
let result = map_mode(gateway_modes::ASK, ConnectorAgentType::Codex);
|
||||
assert_eq!(result.mapped_id, gateway_modes::ASK);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_mode_gemini_passthrough() {
|
||||
// Gemini currently passes through with warning
|
||||
let result = map_mode(gateway_modes::ASK, ConnectorAgentType::Gemini);
|
||||
assert_eq!(result.mapped_id, gateway_modes::ASK);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_model_claude_exact() {
|
||||
let result = map_model(gateway_models::SIMPLE, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_models::HAIKU);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = map_model(gateway_models::DAILYDRIVER, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_models::SONNET);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = map_model(gateway_models::HIGH, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_models::OPUS);
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_model_claude_unknown() {
|
||||
let result = map_model("unknown-model", ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, claude_models::SONNET);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_model_custom_passthrough() {
|
||||
let result = map_model("custom-model", ConnectorAgentType::Custom);
|
||||
assert_eq!(result.mapped_id, "custom-model");
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_model_codex_passthrough() {
|
||||
let result = map_model(gateway_models::SIMPLE, ConnectorAgentType::Codex);
|
||||
assert_eq!(result.mapped_id, gateway_models::SIMPLE);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_model_gemini_passthrough() {
|
||||
let result = map_model(gateway_models::SIMPLE, ConnectorAgentType::Gemini);
|
||||
assert_eq!(result.mapped_id, gateway_models::SIMPLE);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Reverse mapping tests: Agent → Gateway
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_reverse_map_mode_claude_exact() {
|
||||
let result = reverse_map_mode(claude_modes::DEFAULT, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_modes::ASK);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = reverse_map_mode(claude_modes::PLAN, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_modes::PLAN);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = reverse_map_mode(claude_modes::ACCEPT_EDITS, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_modes::WRITE);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = reverse_map_mode(claude_modes::BYPASS_PERMISSIONS, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_modes::YOLO);
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_map_mode_claude_unknown() {
|
||||
let result = reverse_map_mode("unknown-mode", ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_modes::ASK);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_map_mode_custom_passthrough() {
|
||||
let result = reverse_map_mode("custom-mode", ConnectorAgentType::Custom);
|
||||
assert_eq!(result.mapped_id, "custom-mode");
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_map_model_claude_exact() {
|
||||
let result = reverse_map_model(claude_models::HAIKU, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_models::SIMPLE);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = reverse_map_model(claude_models::SONNET, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_models::DAILYDRIVER);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
let result = reverse_map_model(claude_models::OPUS, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_models::HIGH);
|
||||
assert!(result.warning.is_none());
|
||||
|
||||
// Claude's "default" also maps to dailydriver
|
||||
let result = reverse_map_model(claude_models::DEFAULT, ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_models::DAILYDRIVER);
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_map_model_claude_unknown() {
|
||||
let result = reverse_map_model("unknown-model", ConnectorAgentType::Claude);
|
||||
assert_eq!(result.mapped_id, gateway_models::DAILYDRIVER);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_map_model_custom_passthrough() {
|
||||
let result = reverse_map_model("custom-model", ConnectorAgentType::Custom);
|
||||
assert_eq!(result.mapped_id, "custom-model");
|
||||
assert!(result.warning.is_none());
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Round-trip tests: Gateway → Agent → Gateway
|
||||
// ==========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_mode_round_trip_claude() {
|
||||
// Test that we can map Gateway → Claude → Gateway without losing meaning
|
||||
for (gateway_mode, expected_claude, expected_gateway) in [
|
||||
(
|
||||
gateway_modes::ASK,
|
||||
claude_modes::DEFAULT,
|
||||
gateway_modes::ASK,
|
||||
),
|
||||
(gateway_modes::PLAN, claude_modes::PLAN, gateway_modes::PLAN),
|
||||
(
|
||||
gateway_modes::WRITE,
|
||||
claude_modes::ACCEPT_EDITS,
|
||||
gateway_modes::WRITE,
|
||||
),
|
||||
(
|
||||
gateway_modes::YOLO,
|
||||
claude_modes::BYPASS_PERMISSIONS,
|
||||
gateway_modes::YOLO,
|
||||
),
|
||||
] {
|
||||
let forward = map_mode(gateway_mode, ConnectorAgentType::Claude);
|
||||
assert_eq!(
|
||||
forward.mapped_id, expected_claude,
|
||||
"Forward mapping failed for {}",
|
||||
gateway_mode
|
||||
);
|
||||
|
||||
let reverse = reverse_map_mode(&forward.mapped_id, ConnectorAgentType::Claude);
|
||||
assert_eq!(
|
||||
reverse.mapped_id, expected_gateway,
|
||||
"Reverse mapping failed for {}",
|
||||
gateway_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_round_trip_claude() {
|
||||
// Test that we can map Gateway → Claude → Gateway without losing meaning
|
||||
for (gateway_model, expected_claude, expected_gateway) in [
|
||||
(
|
||||
gateway_models::SIMPLE,
|
||||
claude_models::HAIKU,
|
||||
gateway_models::SIMPLE,
|
||||
),
|
||||
(
|
||||
gateway_models::DAILYDRIVER,
|
||||
claude_models::SONNET,
|
||||
gateway_models::DAILYDRIVER,
|
||||
),
|
||||
(
|
||||
gateway_models::HIGH,
|
||||
claude_models::OPUS,
|
||||
gateway_models::HIGH,
|
||||
),
|
||||
] {
|
||||
let forward = map_model(gateway_model, ConnectorAgentType::Claude);
|
||||
assert_eq!(
|
||||
forward.mapped_id, expected_claude,
|
||||
"Forward mapping failed for {}",
|
||||
gateway_model
|
||||
);
|
||||
|
||||
let reverse = reverse_map_model(&forward.mapped_id, ConnectorAgentType::Claude);
|
||||
assert_eq!(
|
||||
reverse.mapped_id, expected_gateway,
|
||||
"Reverse mapping failed for {}",
|
||||
gateway_model
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,930 @@
|
||||
//! Connector abstraction layer
|
||||
//!
|
||||
//! This module provides the connector abstraction that allows dirigent_core to
|
||||
//! manage long-lived connections to external agent systems (OpenCode, ACP, etc.).
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! Each connector:
|
||||
//! - Runs in its own async task with lifecycle management
|
||||
//! - Has a command channel (mpsc) for receiving control commands
|
||||
//! - Has an event broadcast channel for publishing events to subscribers
|
||||
//! - Tracks its own state (Initializing, Connecting, Ready, Error, Stopped)
|
||||
//! - Is owned by a specific user (for authorization)
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use dirigent_core::connectors::{Connector, ConnectorCommand};
|
||||
//! use tokio::sync::broadcast;
|
||||
//!
|
||||
//! async fn example(connector: impl Connector) {
|
||||
//! // Subscribe to connector events
|
||||
//! let mut event_rx = connector.subscribe();
|
||||
//!
|
||||
//! // Send a command
|
||||
//! let cmd_tx = connector.command_tx();
|
||||
//! cmd_tx.send(ConnectorCommand::ListSessions).await.ok();
|
||||
//!
|
||||
//! // Receive events
|
||||
//! while let Ok(event) = event_rx.recv().await {
|
||||
//! println!("Received event: {:?}", event);
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::types::{ConnectorErrorKind, ConnectorId, ConnectorKind, ConnectorState, UserId};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
// Connector implementations
|
||||
pub mod acceptor;
|
||||
pub mod acp;
|
||||
pub mod fingerprint;
|
||||
pub mod gateway;
|
||||
pub mod opencode;
|
||||
|
||||
pub use fingerprint::compute_fingerprint;
|
||||
|
||||
/// Commands that can be sent to a connector
|
||||
///
|
||||
/// These commands control connector behavior and trigger operations on the
|
||||
/// underlying agent system. Commands are sent via the connector's command
|
||||
/// channel and processed asynchronously by the connector's task loop.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ConnectorCommand {
|
||||
/// Request a list of all sessions
|
||||
///
|
||||
/// The connector will query the underlying agent system and emit
|
||||
/// a SessionsListed event with the results.
|
||||
ListSessions,
|
||||
|
||||
/// Request messages for a specific session
|
||||
///
|
||||
/// The connector will fetch messages for the given session and emit
|
||||
/// a MessagesListed event with the results.
|
||||
ListMessages {
|
||||
/// ID of the session to list messages from
|
||||
session_id: String,
|
||||
},
|
||||
|
||||
/// Create a new session
|
||||
///
|
||||
/// The connector will create a new session with the agent system and emit
|
||||
/// a SessionCreated event with the new session information.
|
||||
CreateSession {
|
||||
/// Optional current working directory for the session
|
||||
cwd: Option<String>,
|
||||
/// Optional project ID to associate with the session
|
||||
project_id: Option<String>,
|
||||
/// Session ownership model (internal UI session vs external forwarded session)
|
||||
ownership: dirigent_protocol::SessionOwnership,
|
||||
},
|
||||
|
||||
/// Load an existing session
|
||||
///
|
||||
/// The connector will load an existing session (if the protocol supports it)
|
||||
/// and replay its history. This may emit multiple SessionUpdate events during
|
||||
/// replay.
|
||||
LoadSession {
|
||||
/// ID of the session to load
|
||||
session_id: String,
|
||||
/// Working directory (project path from archivist metadata or session/list)
|
||||
cwd: String,
|
||||
/// MCP server configurations for the agent
|
||||
mcp_servers: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// Send a message to a session
|
||||
///
|
||||
/// The connector will send the message to the agent system and emit
|
||||
/// streaming events as the response is generated.
|
||||
SendMessage {
|
||||
/// ID of the session to send to
|
||||
session_id: String,
|
||||
/// Message text content
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Cancel generation in a session
|
||||
///
|
||||
/// Requests the agent to stop generating the current response.
|
||||
CancelGeneration {
|
||||
/// ID of the session to cancel generation in
|
||||
session_id: String,
|
||||
},
|
||||
|
||||
/// Attempt to reconnect the connector
|
||||
///
|
||||
/// This is useful for recovering from transient connection failures.
|
||||
/// The connector will transition to Connecting state and attempt to
|
||||
/// re-establish its connection to the agent system.
|
||||
Reconnect,
|
||||
|
||||
/// Shutdown the connector gracefully
|
||||
///
|
||||
/// The connector will clean up resources, close connections, and
|
||||
/// transition to Stopped state. The connector's task loop will exit.
|
||||
Shutdown,
|
||||
|
||||
/// Respond to an agent-initiated request
|
||||
///
|
||||
/// This command allows external systems (like the ACP Server) to inject
|
||||
/// responses for pending agent requests (e.g., permission prompts).
|
||||
/// The connector will look up the pending request by request_id and
|
||||
/// send the response to the agent via transport.
|
||||
AgentResponse {
|
||||
/// The request ID from the agent (for correlation)
|
||||
request_id: serde_json::Value,
|
||||
/// The response to send back to the agent
|
||||
response: serde_json::Value,
|
||||
},
|
||||
|
||||
/// Set the session mode
|
||||
///
|
||||
/// Change the active mode for a session. The mode determines behavior
|
||||
/// characteristics like conversation style or capabilities.
|
||||
/// The connector will send the mode change request to the agent system.
|
||||
SetSessionMode {
|
||||
/// ID of the session to update
|
||||
session_id: String,
|
||||
/// ID of the mode to switch to
|
||||
mode_id: String,
|
||||
},
|
||||
|
||||
/// Set the session model
|
||||
///
|
||||
/// Change the active model for a session. The model determines which
|
||||
/// LLM/agent backend is used for processing.
|
||||
/// The connector will send the model change request to the agent system.
|
||||
SetSessionModel {
|
||||
/// ID of the session to update
|
||||
session_id: String,
|
||||
/// ID of the model to switch to
|
||||
model_id: String,
|
||||
},
|
||||
|
||||
/// Close a session (release agent resources, session remains listable).
|
||||
/// Only works when agent supports sessionCapabilities.close.
|
||||
CloseSession {
|
||||
session_id: String,
|
||||
},
|
||||
|
||||
/// Set a config option value (preferred over SetSessionMode/SetSessionModel).
|
||||
/// Uses session/set_config_option per ACP spec.
|
||||
SetConfigOption {
|
||||
session_id: String,
|
||||
config_id: String,
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Trait for connector implementations
|
||||
///
|
||||
/// This trait defines the interface that all connectors must implement.
|
||||
/// It is object-safe, allowing for dynamic dispatch via `dyn Connector`.
|
||||
///
|
||||
/// # Object Safety
|
||||
///
|
||||
/// All methods return references or simple Copy types to ensure the trait
|
||||
/// can be used as a trait object. Methods that need owned values use
|
||||
/// cloneable types like `mpsc::Sender` and `broadcast::Receiver`.
|
||||
pub trait Connector: Send + Sync {
|
||||
/// Get the unique identifier for this connector
|
||||
fn id(&self) -> &ConnectorId;
|
||||
|
||||
/// Get the type of connector (OpenCode, Acp, Mock, etc.)
|
||||
fn kind(&self) -> ConnectorKind;
|
||||
|
||||
/// Get the user who owns this connector
|
||||
///
|
||||
/// Used for authorization checks when routing commands and operations.
|
||||
fn owner(&self) -> &UserId;
|
||||
|
||||
/// Get the human-readable title for this connector
|
||||
///
|
||||
/// This is typically set during connector creation and used for display
|
||||
/// in UI components.
|
||||
fn title(&self) -> &str;
|
||||
|
||||
/// Get the current state of this connector
|
||||
///
|
||||
/// The state reflects the connector's position in its lifecycle:
|
||||
/// - Initializing: Just created, not yet connecting
|
||||
/// - Connecting: Attempting to establish connection
|
||||
/// - Ready: Connected and operational
|
||||
/// - Error: Encountered a failure (with error message)
|
||||
/// - Stopped: Shutdown or unrecoverable error
|
||||
fn state(&self) -> ConnectorState;
|
||||
|
||||
/// Get a sender for sending commands to this connector
|
||||
///
|
||||
/// Commands sent via this channel will be processed asynchronously
|
||||
/// by the connector's task loop. The sender is cloneable and can be
|
||||
/// shared across multiple clients.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An mpsc sender that can be used to send `ConnectorCommand` values.
|
||||
fn command_tx(&self) -> mpsc::Sender<ConnectorCommand>;
|
||||
|
||||
/// Subscribe to events from this connector
|
||||
///
|
||||
/// Returns a broadcast receiver that will receive all events published
|
||||
/// by this connector. Each call to subscribe creates a new independent
|
||||
/// receiver.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A broadcast receiver for `dirigent_protocol::Event` values.
|
||||
///
|
||||
/// # Notes
|
||||
///
|
||||
/// Broadcast channels have a bounded capacity. If a receiver falls too
|
||||
/// far behind (doesn't consume events fast enough), it will miss events
|
||||
/// and receive a `RecvError::Lagged` error.
|
||||
fn subscribe(&self) -> broadcast::Receiver<dirigent_protocol::Event>;
|
||||
|
||||
/// Stop this connector gracefully
|
||||
///
|
||||
/// This is a convenience method that sends a Shutdown command to the
|
||||
/// connector's command channel. The connector will clean up and transition
|
||||
/// to Stopped state asynchronously.
|
||||
fn stop(&self);
|
||||
|
||||
/// Get available commands/tools for this connector
|
||||
///
|
||||
/// Returns a list of commands that this connector exposes to external
|
||||
/// ACP clients. These commands will be forwarded when sessions are
|
||||
/// routed to this connector.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of command definitions with name and optional description.
|
||||
/// Returns an empty vector if the connector doesn't expose commands.
|
||||
fn get_available_commands(&self) -> Vec<crate::acp::protocol::streaming::Command> {
|
||||
// Default implementation: no commands
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to a running connector
|
||||
///
|
||||
/// This struct contains all the state and channels needed to interact with
|
||||
/// a connector. It implements the `Connector` trait and can be cloned to
|
||||
/// create multiple handles to the same underlying connector.
|
||||
///
|
||||
/// # Lifecycle
|
||||
///
|
||||
/// A ConnectorHandle is typically created by the CoreRuntime when a connector
|
||||
/// is instantiated. The handle contains:
|
||||
/// - Metadata (id, kind, owner, title)
|
||||
/// - Shared state protected by RwLock
|
||||
/// - Command channel sender
|
||||
/// - Event broadcast sender (for publishing events)
|
||||
/// - Optional task handle (for graceful shutdown)
|
||||
///
|
||||
/// # Cloning
|
||||
///
|
||||
/// ConnectorHandle uses Arc internally, so cloning is cheap and creates
|
||||
/// a new handle to the same connector. All clones share the same state
|
||||
/// and channels.
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectorHandle {
|
||||
/// Unique identifier for this connector
|
||||
id: ConnectorId,
|
||||
|
||||
/// Type of connector (OpenCode, Acp, Mock, etc.)
|
||||
kind: ConnectorKind,
|
||||
|
||||
/// User who owns this connector
|
||||
owner: UserId,
|
||||
|
||||
/// Human-readable title for display
|
||||
title: String,
|
||||
|
||||
/// Shared connector state
|
||||
///
|
||||
/// Protected by RwLock to allow concurrent reads and exclusive writes.
|
||||
/// The connector's task loop updates this as state transitions occur.
|
||||
state: Arc<RwLock<ConnectorState>>,
|
||||
|
||||
/// Sender for commands to the connector
|
||||
///
|
||||
/// Commands sent via this channel are processed by the connector's
|
||||
/// async task loop.
|
||||
cmd_tx: mpsc::Sender<ConnectorCommand>,
|
||||
|
||||
/// Broadcast sender for events
|
||||
///
|
||||
/// The connector publishes events to this channel. Subscribers get
|
||||
/// receivers via the `subscribe()` method.
|
||||
events_tx: broadcast::Sender<dirigent_protocol::Event>,
|
||||
|
||||
/// Join handle for the connector's background task
|
||||
///
|
||||
/// This is None until the connector is started. Once started, this
|
||||
/// handle can be used to wait for the task to complete during shutdown.
|
||||
task_join: Option<Arc<RwLock<Option<JoinHandle<()>>>>>,
|
||||
|
||||
/// Connector-specific configuration as JSON
|
||||
///
|
||||
/// This stores the serialized configuration parameters (e.g., OpenCodeConfig)
|
||||
/// that were used to create this connector. It is preserved across restarts
|
||||
/// to allow recreation of the connector instance with the same parameters.
|
||||
config: Arc<RwLock<serde_json::Value>>,
|
||||
|
||||
/// Resolved working directory for this connector
|
||||
///
|
||||
/// This is the actual working directory path resolved from the connector
|
||||
/// configuration and global settings. It is computed once during creation
|
||||
/// and stored for quick access.
|
||||
working_directory: Arc<RwLock<Option<std::path::PathBuf>>>,
|
||||
|
||||
/// T034: Optional custom icon path for this connector
|
||||
///
|
||||
/// If set, the UI should display this custom icon instead of the default
|
||||
/// connector type emoji.
|
||||
icon_path: Option<String>,
|
||||
|
||||
/// T035: Show connector type emoji as overlay on custom icon
|
||||
///
|
||||
/// When true and icon_path is set, the connector type emoji should appear
|
||||
/// as a small overlay in the lower-right corner of the custom icon.
|
||||
show_type_overlay: bool,
|
||||
|
||||
/// Structured error classification for the current error state.
|
||||
///
|
||||
/// Shared with the connector's background task via Arc. When `ConnectorState`
|
||||
/// is `Error`, this provides a machine-readable classification. Cleared to
|
||||
/// `None` when the connector recovers.
|
||||
error_kind: Arc<RwLock<Option<ConnectorErrorKind>>>,
|
||||
|
||||
/// Dynamic commands reported by the remote agent.
|
||||
///
|
||||
/// For ACP connectors, the agent sends `available_commands_update` notifications
|
||||
/// with its current slash commands. These are stored here so that
|
||||
/// `get_available_commands()` can return them. For non-ACP connectors this
|
||||
/// stays empty and static commands are returned based on connector kind.
|
||||
available_commands: Arc<RwLock<Vec<crate::acp::protocol::streaming::Command>>>,
|
||||
}
|
||||
|
||||
impl ConnectorHandle {
|
||||
/// Create a new ConnectorHandle
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - Unique identifier for this connector
|
||||
/// * `kind` - Type of connector (OpenCode, Acp, Mock, etc.)
|
||||
/// * `owner` - User who owns this connector
|
||||
/// * `title` - Human-readable title for display
|
||||
/// * `cmd_tx` - Sender for commands to the connector
|
||||
/// * `events_tx` - Broadcast sender for events
|
||||
/// * `config` - Connector-specific configuration as JSON
|
||||
/// * `working_directory` - Optional working directory path
|
||||
/// * `icon_path` - T034: Optional custom icon path
|
||||
/// * `show_type_overlay` - T035: Show connector type emoji as overlay
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new ConnectorHandle with state initialized to `Initializing`
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: ConnectorId,
|
||||
kind: ConnectorKind,
|
||||
owner: UserId,
|
||||
title: String,
|
||||
cmd_tx: mpsc::Sender<ConnectorCommand>,
|
||||
events_tx: broadcast::Sender<dirigent_protocol::Event>,
|
||||
config: serde_json::Value,
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
icon_path: Option<String>,
|
||||
show_type_overlay: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
owner,
|
||||
title,
|
||||
state: Arc::new(RwLock::new(ConnectorState::Initializing)),
|
||||
cmd_tx,
|
||||
events_tx,
|
||||
task_join: Some(Arc::new(RwLock::new(None))),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
working_directory: Arc::new(RwLock::new(working_directory)),
|
||||
icon_path,
|
||||
show_type_overlay,
|
||||
error_kind: Arc::new(RwLock::new(None)),
|
||||
available_commands: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new ConnectorHandle with a shared state Arc
|
||||
///
|
||||
/// This constructor allows the handle to share the same state Arc as the
|
||||
/// connector implementation, ensuring state updates from the connector's
|
||||
/// background task are visible through the handle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - Unique identifier for this connector
|
||||
/// * `kind` - Type of connector (OpenCode, Acp, Mock, etc.)
|
||||
/// * `owner` - User who owns this connector
|
||||
/// * `title` - Human-readable title for display
|
||||
/// * `state` - Shared state Arc from the connector
|
||||
/// * `cmd_tx` - Sender for commands to the connector
|
||||
/// * `events_tx` - Broadcast sender for events
|
||||
/// * `config` - Connector-specific configuration as JSON
|
||||
/// * `working_directory` - Optional working directory path
|
||||
/// * `icon_path` - T034: Optional custom icon path
|
||||
/// * `show_type_overlay` - T035: Show connector type emoji as overlay
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new ConnectorHandle sharing the provided state Arc
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_state(
|
||||
id: ConnectorId,
|
||||
kind: ConnectorKind,
|
||||
owner: UserId,
|
||||
title: String,
|
||||
state: Arc<RwLock<ConnectorState>>,
|
||||
cmd_tx: mpsc::Sender<ConnectorCommand>,
|
||||
events_tx: broadcast::Sender<dirigent_protocol::Event>,
|
||||
config: serde_json::Value,
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
icon_path: Option<String>,
|
||||
show_type_overlay: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
kind,
|
||||
owner,
|
||||
title,
|
||||
state,
|
||||
cmd_tx,
|
||||
events_tx,
|
||||
task_join: Some(Arc::new(RwLock::new(None))),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
working_directory: Arc::new(RwLock::new(working_directory)),
|
||||
icon_path,
|
||||
show_type_overlay,
|
||||
error_kind: Arc::new(RwLock::new(None)),
|
||||
available_commands: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the state RwLock
|
||||
///
|
||||
/// This is useful for connector implementations that need to update
|
||||
/// the state as the connector progresses through its lifecycle.
|
||||
pub fn state_lock(&self) -> Arc<RwLock<ConnectorState>> {
|
||||
Arc::clone(&self.state)
|
||||
}
|
||||
|
||||
/// Get the current error classification for this connector.
|
||||
///
|
||||
/// Returns `None` when the connector is healthy, or `Some(kind)` when
|
||||
/// the connector is in an error state with a classified error.
|
||||
/// Uses `try_read()` to avoid blocking, returning `None` if the lock is held.
|
||||
pub fn error_kind(&self) -> Option<ConnectorErrorKind> {
|
||||
match self.error_kind.try_read() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the error_kind Arc with one shared from a connector implementation.
|
||||
///
|
||||
/// This allows the ConnectorHandle to observe the same error_kind state
|
||||
/// that the connector's background task updates.
|
||||
pub fn set_error_kind_arc(&mut self, error_kind: Arc<RwLock<Option<ConnectorErrorKind>>>) {
|
||||
self.error_kind = error_kind;
|
||||
}
|
||||
|
||||
/// Get a reference to the events broadcast sender
|
||||
///
|
||||
/// This is useful for connector implementations that need to publish
|
||||
/// events to subscribers.
|
||||
pub fn events_sender(&self) -> broadcast::Sender<dirigent_protocol::Event> {
|
||||
self.events_tx.clone()
|
||||
}
|
||||
|
||||
/// Set the task join handle
|
||||
///
|
||||
/// This should be called by connector implementations after spawning
|
||||
/// their background task. The handle is used during shutdown to wait
|
||||
/// for the task to complete gracefully.
|
||||
pub async fn set_task_handle(&self, handle: JoinHandle<()>) {
|
||||
if let Some(task_join) = &self.task_join {
|
||||
let mut guard = task_join.write().await;
|
||||
*guard = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the task join handle (if set)
|
||||
///
|
||||
/// This is useful during shutdown to wait for the connector's task
|
||||
/// to complete.
|
||||
pub async fn take_task_handle(&self) -> Option<JoinHandle<()>> {
|
||||
if let Some(task_join) = &self.task_join {
|
||||
let mut guard = task_join.write().await;
|
||||
guard.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the config RwLock
|
||||
///
|
||||
/// This returns a clone of the config Arc, allowing access to the
|
||||
/// connector's configuration. This is primarily used during restart
|
||||
/// to recreate the connector with the same parameters.
|
||||
pub fn config(&self) -> Arc<RwLock<serde_json::Value>> {
|
||||
Arc::clone(&self.config)
|
||||
}
|
||||
|
||||
/// Get a cloned copy of the connector configuration
|
||||
///
|
||||
/// This is a convenience method that locks the config and returns
|
||||
/// a cloned copy of the JSON value. Use this when you need to read
|
||||
/// the config without holding the lock.
|
||||
pub async fn get_config_cloned(&self) -> serde_json::Value {
|
||||
let config_lock = self.config.read().await;
|
||||
config_lock.clone()
|
||||
}
|
||||
|
||||
/// Update the stored configuration
|
||||
///
|
||||
/// This method replaces the connector's configuration with the provided value.
|
||||
/// The changes take effect immediately in the stored config but only affect
|
||||
/// connector behavior after a restart.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - The new configuration value to store
|
||||
pub async fn set_config(&self, config: serde_json::Value) {
|
||||
let mut cfg = self.config.write().await;
|
||||
*cfg = config;
|
||||
}
|
||||
|
||||
/// Get the resolved working directory for this connector
|
||||
///
|
||||
/// Returns the working directory path that was resolved during connector
|
||||
/// creation. This may be a custom path set in the connector configuration,
|
||||
/// or the default project directory if no custom path was specified.
|
||||
pub fn working_directory(&self) -> Option<std::path::PathBuf> {
|
||||
// Try to read the working directory without blocking
|
||||
if let Ok(guard) = self.working_directory.try_read() {
|
||||
guard.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// T034: Get the custom icon path for this connector
|
||||
///
|
||||
/// Returns the optional custom icon path if set. The UI should use this
|
||||
/// icon instead of the default connector type emoji when present.
|
||||
pub fn icon_path(&self) -> Option<&str> {
|
||||
self.icon_path.as_deref()
|
||||
}
|
||||
|
||||
/// T035: Check if type overlay should be shown on custom icon
|
||||
///
|
||||
/// Returns true if the connector type emoji should appear as a small
|
||||
/// overlay in the lower-right corner of the custom icon.
|
||||
pub fn show_type_overlay(&self) -> bool {
|
||||
self.show_type_overlay
|
||||
}
|
||||
|
||||
/// Set the icon path for this connector
|
||||
///
|
||||
/// Updates the custom icon path. This change is reflected immediately.
|
||||
pub fn set_icon_path(&mut self, icon_path: Option<String>) {
|
||||
self.icon_path = icon_path;
|
||||
}
|
||||
|
||||
/// Set whether to show type overlay on custom icon
|
||||
///
|
||||
/// Updates the overlay setting. This change is reflected immediately.
|
||||
pub fn set_show_type_overlay(&mut self, show_overlay: bool) {
|
||||
self.show_type_overlay = show_overlay;
|
||||
}
|
||||
|
||||
/// Set the connector title
|
||||
///
|
||||
/// Updates the display title for this connector.
|
||||
pub fn set_title(&mut self, title: String) {
|
||||
self.title = title;
|
||||
}
|
||||
|
||||
/// Set the working directory
|
||||
///
|
||||
/// Updates the working directory for this connector. This is an async operation
|
||||
/// since working_directory is protected by an RwLock.
|
||||
pub async fn set_working_directory(&self, working_directory: Option<std::path::PathBuf>) {
|
||||
let mut wd = self.working_directory.write().await;
|
||||
*wd = working_directory;
|
||||
}
|
||||
|
||||
/// Replace the command sender with a new one
|
||||
///
|
||||
/// This is an internal method used during connector restart to update
|
||||
/// the command channel after recreating the connector instance.
|
||||
///
|
||||
/// # Warning
|
||||
///
|
||||
/// This is a low-level operation intended only for use by CoreRuntime's
|
||||
/// restart_connector method. External callers should not use this method.
|
||||
pub async fn replace_command_tx(&self, _new_tx: mpsc::Sender<ConnectorCommand>) {
|
||||
// We can't replace cmd_tx directly since it's not behind a lock
|
||||
// Instead, we rely on the fact that restart creates a new handle
|
||||
// This method is kept for potential future use with handle mutation
|
||||
// For now, document that restart will update references via CoreRuntime
|
||||
}
|
||||
|
||||
/// Update the dynamic available commands for this connector.
|
||||
///
|
||||
/// Called when the remote agent sends an `available_commands_update`
|
||||
/// notification. The stored commands are returned by
|
||||
/// `get_available_commands()` for ACP (and any other dynamic) connectors.
|
||||
pub fn set_available_commands(
|
||||
&self,
|
||||
commands: Vec<crate::acp::protocol::streaming::Command>,
|
||||
) {
|
||||
if let Ok(mut guard) = self.available_commands.try_write() {
|
||||
*guard = commands;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Connector for ConnectorHandle {
|
||||
fn id(&self) -> &ConnectorId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn kind(&self) -> ConnectorKind {
|
||||
self.kind.clone()
|
||||
}
|
||||
|
||||
fn owner(&self) -> &UserId {
|
||||
&self.owner
|
||||
}
|
||||
|
||||
fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
fn state(&self) -> ConnectorState {
|
||||
// Try to read the state without blocking
|
||||
// If the lock is held, we return the last known state
|
||||
// This is acceptable for status queries where eventual consistency is fine
|
||||
match self.state.try_read() {
|
||||
Ok(state_lock) => state_lock.clone(),
|
||||
Err(_) => {
|
||||
// Lock is held, return Initializing as a safe default
|
||||
// In practice, this is extremely rare and only happens during
|
||||
// state transitions, which are very fast
|
||||
ConnectorState::Initializing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_tx(&self) -> mpsc::Sender<ConnectorCommand> {
|
||||
self.cmd_tx.clone()
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<dirigent_protocol::Event> {
|
||||
self.events_tx.subscribe()
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
// Send shutdown command
|
||||
// We don't wait for it to be processed - this is a cooperative shutdown
|
||||
let cmd_tx = self.cmd_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = cmd_tx.send(ConnectorCommand::Shutdown).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn get_available_commands(&self) -> Vec<crate::acp::protocol::streaming::Command> {
|
||||
// First, read any dynamically-stored commands (populated by ACP agents
|
||||
// via available_commands_update notifications).
|
||||
let dynamic = self
|
||||
.available_commands
|
||||
.try_read()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !dynamic.is_empty() {
|
||||
return dynamic;
|
||||
}
|
||||
|
||||
// Fall back to static commands based on connector kind
|
||||
match self.kind {
|
||||
ConnectorKind::Gateway => vec![
|
||||
crate::acp::protocol::streaming::Command {
|
||||
name: "echo".to_string(),
|
||||
description: Some("Enable or disable echo mode (/echo on|off)".to_string()),
|
||||
},
|
||||
crate::acp::protocol::streaming::Command {
|
||||
name: "help".to_string(),
|
||||
description: Some("Show available commands".to_string()),
|
||||
},
|
||||
crate::acp::protocol::streaming::Command {
|
||||
name: "list-connectors".to_string(),
|
||||
description: Some("Show available connectors".to_string()),
|
||||
},
|
||||
crate::acp::protocol::streaming::Command {
|
||||
name: "select-connector".to_string(),
|
||||
description: Some(
|
||||
"Transfer session to a connector (/select-connector <id>)".to_string(),
|
||||
),
|
||||
},
|
||||
crate::acp::protocol::streaming::Command {
|
||||
name: "claude".to_string(),
|
||||
description: Some("Transfer session to a Claude connector".to_string()),
|
||||
},
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_connector_command_clone() {
|
||||
let cmd1 = ConnectorCommand::ListSessions;
|
||||
let cmd2 = cmd1.clone();
|
||||
|
||||
// Both should be ListSessions variant
|
||||
matches!(cmd1, ConnectorCommand::ListSessions);
|
||||
matches!(cmd2, ConnectorCommand::ListSessions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connector_command_debug() {
|
||||
let cmd = ConnectorCommand::SendMessage {
|
||||
session_id: "test-session".to_string(),
|
||||
text: "Hello".to_string(),
|
||||
};
|
||||
|
||||
let debug_str = format!("{:?}", cmd);
|
||||
assert!(debug_str.contains("SendMessage"));
|
||||
assert!(debug_str.contains("test-session"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_handle_creation() {
|
||||
let (cmd_tx, _cmd_rx) = mpsc::channel(100);
|
||||
let (events_tx, _events_rx) = broadcast::channel(1000);
|
||||
|
||||
let handle = ConnectorHandle::new(
|
||||
"test-connector".to_string(),
|
||||
ConnectorKind::Mock,
|
||||
uuid::Uuid::nil(),
|
||||
"Test Connector".to_string(),
|
||||
cmd_tx,
|
||||
events_tx,
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(handle.id(), "test-connector");
|
||||
assert_eq!(handle.kind(), ConnectorKind::Mock);
|
||||
assert_eq!(*handle.owner(), uuid::Uuid::nil());
|
||||
assert_eq!(handle.title(), "Test Connector");
|
||||
assert_eq!(handle.state(), ConnectorState::Initializing);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_handle_state_update() {
|
||||
let (cmd_tx, _cmd_rx) = mpsc::channel(100);
|
||||
let (events_tx, _events_rx) = broadcast::channel(1000);
|
||||
|
||||
let handle = ConnectorHandle::new(
|
||||
"test-connector".to_string(),
|
||||
ConnectorKind::Mock,
|
||||
uuid::Uuid::nil(),
|
||||
"Test Connector".to_string(),
|
||||
cmd_tx,
|
||||
events_tx,
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
// Update state to Ready
|
||||
{
|
||||
let state_lock = handle.state_lock();
|
||||
let mut state = state_lock.write().await;
|
||||
*state = ConnectorState::Ready;
|
||||
}
|
||||
|
||||
assert_eq!(handle.state(), ConnectorState::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_handle_subscribe() {
|
||||
let (cmd_tx, _cmd_rx) = mpsc::channel(100);
|
||||
let (events_tx, _events_rx) = broadcast::channel(1000);
|
||||
|
||||
let handle = ConnectorHandle::new(
|
||||
"test-connector".to_string(),
|
||||
ConnectorKind::Mock,
|
||||
uuid::Uuid::nil(),
|
||||
"Test Connector".to_string(),
|
||||
cmd_tx,
|
||||
events_tx.clone(),
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
// Subscribe to events
|
||||
let mut rx1 = handle.subscribe();
|
||||
let mut rx2 = handle.subscribe();
|
||||
|
||||
// Send an event
|
||||
let event = dirigent_protocol::Event::Connected;
|
||||
events_tx.send(event.clone()).ok();
|
||||
|
||||
// Both receivers should get the event
|
||||
let received1 = rx1.recv().await.unwrap();
|
||||
let received2 = rx2.recv().await.unwrap();
|
||||
|
||||
matches!(received1, dirigent_protocol::Event::Connected);
|
||||
matches!(received2, dirigent_protocol::Event::Connected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_handle_command_tx() {
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::channel(100);
|
||||
let (events_tx, _events_rx) = broadcast::channel(1000);
|
||||
|
||||
let handle = ConnectorHandle::new(
|
||||
"test-connector".to_string(),
|
||||
ConnectorKind::Mock,
|
||||
uuid::Uuid::nil(),
|
||||
"Test Connector".to_string(),
|
||||
cmd_tx,
|
||||
events_tx,
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
// Send a command
|
||||
let sender = handle.command_tx();
|
||||
sender.send(ConnectorCommand::ListSessions).await.unwrap();
|
||||
|
||||
// Receive the command
|
||||
let cmd = cmd_rx.recv().await.unwrap();
|
||||
matches!(cmd, ConnectorCommand::ListSessions);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_handle_clone() {
|
||||
let (cmd_tx, _cmd_rx) = mpsc::channel(100);
|
||||
let (events_tx, _events_rx) = broadcast::channel(1000);
|
||||
|
||||
let handle1 = ConnectorHandle::new(
|
||||
"test-connector".to_string(),
|
||||
ConnectorKind::Mock,
|
||||
uuid::Uuid::nil(),
|
||||
"Test Connector".to_string(),
|
||||
cmd_tx,
|
||||
events_tx,
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
let handle2 = handle1.clone();
|
||||
|
||||
// Both handles should refer to the same connector
|
||||
assert_eq!(handle1.id(), handle2.id());
|
||||
assert_eq!(handle1.kind(), handle2.kind());
|
||||
|
||||
// Update state via handle1
|
||||
{
|
||||
let state_lock = handle1.state_lock();
|
||||
let mut state = state_lock.write().await;
|
||||
*state = ConnectorState::Ready;
|
||||
}
|
||||
|
||||
// handle2 should see the update
|
||||
assert_eq!(handle2.state(), ConnectorState::Ready);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
//! Error types for dirigent_core
|
||||
//!
|
||||
//! This module defines the core error types used throughout the dirigent_core
|
||||
//! orchestration engine. All operations that can fail should return a `Result<T, CoreError>`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Core error type for dirigent_core operations
|
||||
///
|
||||
/// This enum represents all possible errors that can occur during
|
||||
/// core runtime operations, including connector management, configuration,
|
||||
/// and authorization.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CoreError {
|
||||
/// Resource was not found
|
||||
///
|
||||
/// Used when attempting to access a connector, user, or other resource
|
||||
/// that doesn't exist in the runtime registry.
|
||||
NotFound,
|
||||
|
||||
/// Resource already exists
|
||||
///
|
||||
/// Used when attempting to create a resource (e.g., connector) with an ID
|
||||
/// that is already in use.
|
||||
AlreadyExists,
|
||||
|
||||
/// Invalid configuration provided
|
||||
///
|
||||
/// Used when configuration validation fails, such as invalid URLs,
|
||||
/// missing required fields, or malformed connector parameters.
|
||||
InvalidConfig,
|
||||
|
||||
/// Failed to start a connector or service
|
||||
///
|
||||
/// Used when a connector fails to initialize or start its background task,
|
||||
/// such as connection failures or missing dependencies.
|
||||
StartFailed,
|
||||
|
||||
/// Operation not authorized for the requesting user
|
||||
///
|
||||
/// Used when ownership checks fail or the user doesn't have permission
|
||||
/// to perform the requested operation on a resource.
|
||||
Unauthorized,
|
||||
|
||||
/// Internal error with descriptive message
|
||||
///
|
||||
/// Used for unexpected errors, I/O failures, serialization errors,
|
||||
/// or any other internal issues that don't fit other categories.
|
||||
/// The string provides context about what went wrong.
|
||||
Internal(String),
|
||||
|
||||
/// Operation not valid in current state
|
||||
///
|
||||
/// Used when attempting an operation on a resource that is in an
|
||||
/// incompatible state (e.g., sending commands to an errored connector,
|
||||
/// starting an already-running connector).
|
||||
InvalidState,
|
||||
}
|
||||
|
||||
impl fmt::Display for CoreError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
CoreError::NotFound => {
|
||||
write!(f, "Resource not found")
|
||||
}
|
||||
CoreError::AlreadyExists => {
|
||||
write!(f, "Resource already exists")
|
||||
}
|
||||
CoreError::InvalidConfig => {
|
||||
write!(f, "Invalid configuration provided")
|
||||
}
|
||||
CoreError::StartFailed => {
|
||||
write!(f, "Failed to start connector or service")
|
||||
}
|
||||
CoreError::Unauthorized => {
|
||||
write!(f, "Operation not authorized")
|
||||
}
|
||||
CoreError::Internal(msg) => {
|
||||
write!(f, "Internal error: {}", msg)
|
||||
}
|
||||
CoreError::InvalidState => {
|
||||
write!(f, "Operation not valid in current state")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CoreError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
// None of our error variants wrap other errors currently
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
assert_eq!(CoreError::NotFound.to_string(), "Resource not found");
|
||||
assert_eq!(
|
||||
CoreError::AlreadyExists.to_string(),
|
||||
"Resource already exists"
|
||||
);
|
||||
assert_eq!(
|
||||
CoreError::InvalidConfig.to_string(),
|
||||
"Invalid configuration provided"
|
||||
);
|
||||
assert_eq!(
|
||||
CoreError::StartFailed.to_string(),
|
||||
"Failed to start connector or service"
|
||||
);
|
||||
assert_eq!(
|
||||
CoreError::Unauthorized.to_string(),
|
||||
"Operation not authorized"
|
||||
);
|
||||
assert_eq!(
|
||||
CoreError::Internal("test error".to_string()).to_string(),
|
||||
"Internal error: test error"
|
||||
);
|
||||
assert_eq!(
|
||||
CoreError::InvalidState.to_string(),
|
||||
"Operation not valid in current state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_equality() {
|
||||
assert_eq!(CoreError::NotFound, CoreError::NotFound);
|
||||
assert_eq!(CoreError::AlreadyExists, CoreError::AlreadyExists);
|
||||
assert_eq!(
|
||||
CoreError::Internal("same".to_string()),
|
||||
CoreError::Internal("same".to_string())
|
||||
);
|
||||
assert_ne!(
|
||||
CoreError::Internal("different".to_string()),
|
||||
CoreError::Internal("other".to_string())
|
||||
);
|
||||
assert_ne!(CoreError::NotFound, CoreError::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_is_error_trait() {
|
||||
// Verify that CoreError implements std::error::Error
|
||||
fn assert_is_error<T: std::error::Error>() {}
|
||||
assert_is_error::<CoreError>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_clone() {
|
||||
let error = CoreError::Internal("test".to_string());
|
||||
let cloned = error.clone();
|
||||
assert_eq!(error, cloned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_debug() {
|
||||
let error = CoreError::NotFound;
|
||||
let debug_str = format!("{:?}", error);
|
||||
assert!(debug_str.contains("NotFound"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Dirigent Core
|
||||
//!
|
||||
//! The core functionality of Dirigent:
|
||||
//! - Setup and manage ACP (Agent-Client Protocol) agents
|
||||
//! - Connect as ACP client to ACP agents
|
||||
//! - Manage projects and sessions
|
||||
//! - Handle file access and terminal operations
|
||||
//!
|
||||
//! # Core Runtime Architecture
|
||||
//!
|
||||
//! The runtime provides a centralized orchestrator for managing connectors:
|
||||
//! - `CoreRuntime` - Main runtime that manages connector lifecycle
|
||||
//! - `CoreHandle` - Cloneable handle to the runtime for easy sharing
|
||||
//! - `Connector` - Trait for connector implementations
|
||||
//! - `ConnectorHandle` - Handle to a running connector instance
|
||||
//!
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use dirigent_core::{CoreRuntime, CoreConfig, CoreHandle};
|
||||
//!
|
||||
//! # async fn example() {
|
||||
//! // Create runtime with default config
|
||||
//! let runtime = CoreRuntime::new(CoreConfig::default());
|
||||
//! let handle = CoreHandle::new(runtime);
|
||||
//!
|
||||
//! // List connectors
|
||||
//! let connectors = handle.list_connectors(None).await;
|
||||
//!
|
||||
//! // Subscribe to events via the SharingBus
|
||||
//! let _bus_rx = handle.sharing_bus().subscribe_all().await;
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
// Core types module - always available
|
||||
pub mod types;
|
||||
|
||||
// Plugin system types (scaffolding) - always available
|
||||
pub mod plugins;
|
||||
|
||||
// Tool directive and configuration types - always available (WASM-compatible)
|
||||
pub mod tools;
|
||||
|
||||
// Re-export commonly used types (always available for WASM)
|
||||
pub use types::{
|
||||
ConnectorErrorKind, ConnectorId, ConnectorKind, ConnectorState, ConnectorSummary, User, UserId,
|
||||
UserProfile,
|
||||
};
|
||||
|
||||
// Server-only modules and exports
|
||||
#[cfg(feature = "server")]
|
||||
mod error;
|
||||
#[cfg(feature = "server")]
|
||||
mod runtime;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub use error::CoreError;
|
||||
#[cfg(feature = "server")]
|
||||
pub use runtime::{CoreHandle, CoreRuntime};
|
||||
|
||||
// Re-export Zed agent → ConnectorConfig conversion (used by API)
|
||||
#[cfg(feature = "server")]
|
||||
pub use runtime::zed_detection::{refresh_zed_connector_binaries, zed_agent_to_connector_config};
|
||||
|
||||
// Configuration module (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub mod config;
|
||||
|
||||
// Connectors module - abstraction layer for agent system connections (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub mod connectors;
|
||||
|
||||
// Vendors module - vendor-specific knowledge (CLI detection, mappings, templates)
|
||||
#[cfg(feature = "server")]
|
||||
pub mod vendors;
|
||||
|
||||
// ACP module - Agent-Client Protocol implementation (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub mod acp;
|
||||
|
||||
// Re-export configuration types (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub use config::{
|
||||
apply_template, resolve_default_runtime_working_directory, AcpServerConfig, ConnectorConfig,
|
||||
CoreConfig, TaskConfig,
|
||||
};
|
||||
|
||||
// Re-export connector abstractions (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub use connectors::{Connector, ConnectorCommand, ConnectorHandle};
|
||||
|
||||
// Re-export connector implementations (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub use connectors::opencode::{OpenCodeConfig, OpenCodeConnector};
|
||||
|
||||
// Re-export acceptor types for incoming connections (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub use connectors::acceptor::{
|
||||
Acceptor, AcceptorError, AcceptorHandle, AcceptorId, AcceptorSummary, AcpAcceptor,
|
||||
IncomingSession, SessionRouting,
|
||||
};
|
||||
|
||||
// Re-export gateway connector types (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub use connectors::gateway::{
|
||||
get_transient_commands, merge_with_transient_commands, Command as GatewayCommand,
|
||||
CommandResult, ConnectorListCallback, ConnectorSummaryInfo, EchoConfig, GatewayConfig,
|
||||
GatewayConnector, GatewaySession, SessionTransferCallback,
|
||||
};
|
||||
|
||||
// Session sharing abstraction (server-only)
|
||||
#[cfg(feature = "server")]
|
||||
pub mod sharing;
|
||||
#[cfg(feature = "server")]
|
||||
pub use dirigent_protocol::sharing::{SessionShare, ShareId, ShareSummary};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PluginKind {
|
||||
Observer,
|
||||
Modifier,
|
||||
Provider,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginDefinition {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: PluginKind,
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PluginAssignment {
|
||||
pub plugin_id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectAssignment {
|
||||
pub project_id: uuid::Uuid,
|
||||
pub assigned: bool,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Plugin system types (scaffolding).
|
||||
//! Defines plugin definitions and assignments for connectors and sessions.
|
||||
//! The runtime execution layer is not yet implemented.
|
||||
|
||||
mod definition;
|
||||
|
||||
pub use definition::{PluginAssignment, PluginDefinition, PluginKind, ProjectAssignment};
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Configuration Management
|
||||
//!
|
||||
//! This module handles saving and updating runtime configuration.
|
||||
//! It provides atomic file writes and supports JSON/TOML formats.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::{AcpServerConfig, CoreConfig};
|
||||
use crate::CoreError;
|
||||
|
||||
/// Recursively strip JSON null values from a serde_json::Value.
|
||||
///
|
||||
/// TOML has no null type, so `serde_json::Value::Null` serializes as `()`
|
||||
/// which the toml crate rejects with "unsupported unit type". This function
|
||||
/// removes null entries from objects and arrays before TOML serialization.
|
||||
pub fn strip_json_nulls(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
map.retain(|_, v| !v.is_null());
|
||||
for v in map.values_mut() {
|
||||
strip_json_nulls(v);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
arr.retain(|v| !v.is_null());
|
||||
for v in arr.iter_mut() {
|
||||
strip_json_nulls(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a config path is project-local (relative or under CWD).
|
||||
fn is_project_local(path: &Path) -> bool {
|
||||
path.is_relative() || path.starts_with(std::env::current_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Promote a project-local config path to the user config directory,
|
||||
/// preserving the file extension. Returns the original path if the
|
||||
/// user config directory cannot be resolved.
|
||||
fn promote_to_user_dir(source: &Path) -> PathBuf {
|
||||
if let Ok(paths) = dirigent_config::DirigentPaths::resolve() {
|
||||
let user_dir = paths.config_dir().to_path_buf();
|
||||
let _ = std::fs::create_dir_all(&user_dir);
|
||||
let ext = source
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("toml");
|
||||
user_dir.join(format!("dirigent.{}", ext))
|
||||
} else {
|
||||
source.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the default user config path for new saves.
|
||||
fn default_user_config_path() -> PathBuf {
|
||||
if let Ok(paths) = dirigent_config::DirigentPaths::resolve() {
|
||||
let user_dir = paths.config_dir().to_path_buf();
|
||||
let _ = std::fs::create_dir_all(&user_dir);
|
||||
user_dir.join("dirigent.toml")
|
||||
} else {
|
||||
PathBuf::from("dirigent.json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the ACP server configuration
|
||||
///
|
||||
/// This updates the in-memory configuration. Call `save_config` to persist.
|
||||
pub async fn update_acp_server_config(
|
||||
config: &Arc<RwLock<CoreConfig>>,
|
||||
acp_config: AcpServerConfig,
|
||||
) -> Result<(), CoreError> {
|
||||
let mut cfg = config.write().await;
|
||||
cfg.acp_server = Some(acp_config);
|
||||
info!("Updated ACP Server configuration");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the current configuration to disk
|
||||
///
|
||||
/// This method serializes the current runtime configuration to JSON or TOML format
|
||||
/// and writes it to the filesystem atomically (write to temp file, then rename).
|
||||
///
|
||||
/// The save path is determined as follows:
|
||||
/// 1. The file the config was originally loaded from (`config_source_path`),
|
||||
/// promoted to the user config directory if it was project-local
|
||||
/// 2. User config directory default (`~/.config/dirigent/dirigent.toml`)
|
||||
///
|
||||
/// Project-local config files (relative paths or under CWD) are treated as
|
||||
/// read-only: on save the config is promoted to the user config directory
|
||||
/// so that the original project-local file is never modified.
|
||||
///
|
||||
/// The file format is determined by the extension of the save path.
|
||||
pub async fn save_config(config: &Arc<RwLock<CoreConfig>>) -> Result<(), CoreError> {
|
||||
use std::io::Write;
|
||||
|
||||
// Lock config for read and create a sanitized copy for saving.
|
||||
// Zed-sourced connectors (source == "zed") are transient runtime entries.
|
||||
// They should not be persisted — stripping them prevents ghost duplicates.
|
||||
let config_guard = config.read().await;
|
||||
let mut save_config = config_guard.clone();
|
||||
let zed_count = save_config
|
||||
.connectors
|
||||
.iter()
|
||||
.filter(|c| c.source.as_deref() == Some("zed"))
|
||||
.count();
|
||||
if zed_count > 0 {
|
||||
debug!(
|
||||
count = zed_count,
|
||||
"Stripping transient Zed-sourced connectors from saved config"
|
||||
);
|
||||
save_config
|
||||
.connectors
|
||||
.retain(|c| c.source.as_deref() != Some("zed"));
|
||||
}
|
||||
// Capture the source path before releasing the lock
|
||||
let source_path = config_guard.config_source_path.clone();
|
||||
drop(config_guard); // Release lock early — we work from the cloned copy
|
||||
|
||||
// Determine save path with promotion logic:
|
||||
// - Project-local sources are promoted to the user config directory
|
||||
// - Explicit user-dir or env-var paths are used as-is
|
||||
let save_path = if let Some(ref source) = source_path {
|
||||
if is_project_local(source) {
|
||||
let promoted = promote_to_user_dir(source);
|
||||
info!(
|
||||
from = %source.display(),
|
||||
to = %promoted.display(),
|
||||
"Promoting project-local config to user config directory"
|
||||
);
|
||||
promoted
|
||||
} else {
|
||||
source.clone()
|
||||
}
|
||||
} else {
|
||||
default_user_config_path()
|
||||
};
|
||||
|
||||
// Update config_source_path so subsequent saves go to the same location
|
||||
if source_path.as_ref() != Some(&save_path) {
|
||||
config.write().await.config_source_path = Some(save_path.clone());
|
||||
}
|
||||
|
||||
info!(path = ?save_path, "Saving configuration");
|
||||
|
||||
// Determine format by extension
|
||||
let contents = match save_path.extension().and_then(|s| s.to_str()) {
|
||||
Some("toml") => {
|
||||
debug!("Serializing config as TOML");
|
||||
// TOML has no null type — strip JSON nulls from connector params
|
||||
for connector in &mut save_config.connectors {
|
||||
strip_json_nulls(&mut connector.params);
|
||||
}
|
||||
toml::to_string_pretty(&save_config).map_err(|e| {
|
||||
error!(error = %e, "Failed to serialize config as TOML");
|
||||
CoreError::Internal(format!("Failed to serialize config as TOML: {}", e))
|
||||
})?
|
||||
}
|
||||
Some("json") | None => {
|
||||
debug!("Serializing config as JSON");
|
||||
serde_json::to_string_pretty(&save_config).map_err(|e| {
|
||||
error!(error = %e, "Failed to serialize config as JSON");
|
||||
CoreError::Internal(format!("Failed to serialize config as JSON: {}", e))
|
||||
})?
|
||||
}
|
||||
Some(ext) => {
|
||||
warn!(
|
||||
extension = ext,
|
||||
"Unsupported config file extension, defaulting to JSON"
|
||||
);
|
||||
serde_json::to_string_pretty(&save_config).map_err(|e| {
|
||||
error!(error = %e, "Failed to serialize config as JSON");
|
||||
CoreError::Internal(format!("Failed to serialize config as JSON: {}", e))
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure the parent directory exists (critical for first save to user config dir)
|
||||
if let Some(parent) = save_path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
error!(error = %e, path = ?parent, "Failed to create config directory");
|
||||
CoreError::Internal(format!(
|
||||
"Failed to create config directory {}: {}",
|
||||
parent.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
info!(path = ?parent, "Created config directory");
|
||||
}
|
||||
}
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
let temp_path = save_path.with_extension("tmp");
|
||||
debug!(temp_path = ?temp_path, "Writing to temporary file");
|
||||
|
||||
// Write to temp file
|
||||
{
|
||||
let mut file = std::fs::File::create(&temp_path).map_err(|e| {
|
||||
error!(error = %e, path = ?temp_path, "Failed to create temp config file");
|
||||
CoreError::Internal(format!(
|
||||
"Failed to create temp config file {}: {}",
|
||||
temp_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
file.write_all(contents.as_bytes()).map_err(|e| {
|
||||
error!(error = %e, path = ?temp_path, "Failed to write to temp config file");
|
||||
CoreError::Internal(format!(
|
||||
"Failed to write to temp config file {}: {}",
|
||||
temp_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
file.sync_all().map_err(|e| {
|
||||
error!(error = %e, path = ?temp_path, "Failed to sync temp config file");
|
||||
CoreError::Internal(format!(
|
||||
"Failed to sync temp config file {}: {}",
|
||||
temp_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Rename temp file to final path
|
||||
debug!(from = ?temp_path, to = ?save_path, "Renaming temp file to final path");
|
||||
std::fs::rename(&temp_path, &save_path).map_err(|e| {
|
||||
error!(error = %e, from = ?temp_path, to = ?save_path, "Failed to rename temp file");
|
||||
CoreError::Internal(format!(
|
||||
"Failed to rename temp file {} to {}: {}",
|
||||
temp_path.display(),
|
||||
save_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
info!(path = ?save_path, "Configuration saved successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::CoreConfig;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_acp_server_config() {
|
||||
let config = Arc::new(RwLock::new(CoreConfig::default()));
|
||||
|
||||
let acp_config = AcpServerConfig {
|
||||
enabled: true,
|
||||
port: Some(3001),
|
||||
allowed_origins: None,
|
||||
max_connections: 100,
|
||||
default_connector_id: None,
|
||||
};
|
||||
|
||||
let result = update_acp_server_config(&config, acp_config).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cfg = config.read().await;
|
||||
assert!(cfg.acp_server.is_some());
|
||||
assert_eq!(cfg.acp_server.as_ref().unwrap().port, Some(3001));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
//! Session Transfer Helpers
|
||||
//!
|
||||
//! This module contains helper functions for transferring sessions between connectors.
|
||||
//! These are primarily used by the Gateway connector when selecting a target connector.
|
||||
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::connectors::ConnectorCommand;
|
||||
use dirigent_protocol::session::{SessionModeState, SessionModelState};
|
||||
|
||||
/// Create a session in a connector and wait for the event
|
||||
///
|
||||
/// Returns the session ID along with optional models/modes from the SessionCreated event.
|
||||
/// The Session struct in SessionCreated already contains models/modes from the
|
||||
/// connector's session/new response.
|
||||
pub async fn create_session_in_connector(
|
||||
cmd_tx: &tokio::sync::mpsc::Sender<ConnectorCommand>,
|
||||
events: &mut broadcast::Receiver<dirigent_protocol::Event>,
|
||||
) -> Result<(String, Option<SessionModelState>, Option<SessionModeState>), String> {
|
||||
if let Err(e) = cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Err(format!("Failed to send create command: {}", e));
|
||||
}
|
||||
|
||||
// Wait for SessionCreated event
|
||||
let timeout = Duration::from_secs(30);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
match tokio::time::timeout(Duration::from_secs(1), events.recv()).await {
|
||||
Ok(Ok(dirigent_protocol::Event::SessionCreated { session, .. })) => {
|
||||
// Extract models/modes directly from the Session struct
|
||||
return Ok((session.id, session.models, session.modes));
|
||||
}
|
||||
Ok(Ok(dirigent_protocol::Event::Error { message })) => {
|
||||
return Err(format!("Connector error: {}", message));
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
return Err("Event channel closed".to_string());
|
||||
}
|
||||
Err(_) => continue, // Timeout on this iteration, keep waiting
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Err("Timeout waiting for session creation".to_string())
|
||||
}
|
||||
|
||||
/// Wait for a session load event
|
||||
///
|
||||
/// Waits for a SessionUpdated or SessionCreated event for the given session ID,
|
||||
/// or returns an error if a SessionError event is received or timeout occurs.
|
||||
pub async fn wait_for_session_event(
|
||||
events: &mut broadcast::Receiver<dirigent_protocol::Event>,
|
||||
session_id: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
match tokio::time::timeout(Duration::from_millis(500), events.recv()).await {
|
||||
Ok(Ok(dirigent_protocol::Event::SessionUpdated { session, .. }))
|
||||
if session.id == session_id =>
|
||||
{
|
||||
return Ok(session.id);
|
||||
}
|
||||
Ok(Ok(dirigent_protocol::Event::SessionCreated { session, .. }))
|
||||
if session.id == session_id =>
|
||||
{
|
||||
return Ok(session.id);
|
||||
}
|
||||
Ok(Ok(dirigent_protocol::Event::SessionError {
|
||||
session_id: sid,
|
||||
error_message,
|
||||
..
|
||||
})) if sid == session_id => {
|
||||
return Err(error_message);
|
||||
}
|
||||
Ok(Err(_)) => return Err("Event channel closed".to_string()),
|
||||
Err(_) => continue,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Session '{}' not found", session_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_for_session_event_timeout() {
|
||||
let (_tx, mut rx) = broadcast::channel::<dirigent_protocol::Event>(10);
|
||||
|
||||
let result =
|
||||
wait_for_session_event(&mut rx, "test-session", Duration::from_millis(100)).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Connector Summary Cache
|
||||
//!
|
||||
//! This module manages a synchronous cache of connector summary information
|
||||
//! that can be accessed from callbacks without an async runtime.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::CoreConfig;
|
||||
use crate::connectors::gateway::ConnectorSummaryInfo;
|
||||
use crate::connectors::{Connector, ConnectorHandle};
|
||||
use crate::types::ConnectorKind;
|
||||
|
||||
/// Update the connector summary cache from the current connectors map
|
||||
///
|
||||
/// This function rebuilds the sync-accessible cache used by GatewayConnector
|
||||
/// callbacks. It should be called after any connector is added or removed.
|
||||
///
|
||||
/// The cache uses a std::sync::RwLock so it can be accessed from synchronous
|
||||
/// callback functions that don't have access to an async runtime.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `connectors` - Read-locked map of connector handles
|
||||
/// * `config` - Read-locked runtime configuration
|
||||
/// * `cache` - The std::sync::RwLock cache to update
|
||||
pub async fn update_connector_summary_cache(
|
||||
connectors: &RwLock<HashMap<String, ConnectorHandle>>,
|
||||
config: &RwLock<CoreConfig>,
|
||||
cache: &Arc<std::sync::RwLock<Vec<ConnectorSummaryInfo>>>,
|
||||
) {
|
||||
let connectors_guard = connectors.read().await;
|
||||
let config_guard = config.read().await;
|
||||
|
||||
let summaries: Vec<ConnectorSummaryInfo> = connectors_guard
|
||||
.values()
|
||||
.map(|handle| {
|
||||
let kind = handle.kind();
|
||||
let connector_id = handle.id();
|
||||
|
||||
// Get agent_type for ACP connectors
|
||||
let agent_type = if kind == ConnectorKind::Acp {
|
||||
config_guard
|
||||
.connectors
|
||||
.iter()
|
||||
.find(|c| c.id.as_deref() == Some(connector_id))
|
||||
.and_then(|cfg| {
|
||||
serde_json::from_value::<crate::connectors::acp::config::AcpConfig>(
|
||||
cfg.params.clone(),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.map(|cfg| cfg.agent_type)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ConnectorSummaryInfo {
|
||||
id: connector_id.to_string(),
|
||||
title: handle.title().to_string(),
|
||||
kind: format!("{:?}", kind),
|
||||
state: format!("{:?}", handle.state()),
|
||||
supports_session_transfer: kind.supports_session_transfer(),
|
||||
agent_type,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Update the sync cache (this lock should never block for long since we're just writing a Vec)
|
||||
if let Ok(mut cache_guard) = cache.write() {
|
||||
*cache_guard = summaries;
|
||||
} else {
|
||||
warn!("Failed to acquire connector summary cache lock for update");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::CoreConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_empty_cache() {
|
||||
let connectors = RwLock::new(HashMap::new());
|
||||
let config = RwLock::new(CoreConfig::default());
|
||||
let cache = Arc::new(std::sync::RwLock::new(Vec::new()));
|
||||
|
||||
update_connector_summary_cache(&connectors, &config, &cache).await;
|
||||
|
||||
let cached = cache.read().unwrap();
|
||||
assert!(cached.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! Zed editor agent detection and connector config generation.
|
||||
//!
|
||||
//! This module detects Zed editor installations and generates `ConnectorConfig`
|
||||
//! entries for discovered ACP agents. It is called during runtime initialization
|
||||
//! to auto-populate connectors from Zed's agent configuration.
|
||||
|
||||
use crate::config::{ConnectorConfig, CoreConfig};
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
use crate::connectors::acp::{AcpConfig, TransportKind};
|
||||
use crate::types::ConnectorKind;
|
||||
use dirigent_tools::EmbeddingConfig;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Default supported features for known ACP agent types.
|
||||
///
|
||||
/// These are conservative defaults based on confirmed agent capabilities.
|
||||
/// Users can override via the connector config UI.
|
||||
fn default_features_for_agent(agent_type: &ConnectorAgentType) -> Vec<String> {
|
||||
match agent_type {
|
||||
ConnectorAgentType::Claude => vec![
|
||||
"cancellation".to_string(),
|
||||
"session_resume".to_string(),
|
||||
"session_list".to_string(),
|
||||
],
|
||||
ConnectorAgentType::Codex => vec![
|
||||
"session_resume".to_string(),
|
||||
],
|
||||
// Gemini: no confirmed features yet (hangs on connect — BUG-7)
|
||||
ConnectorAgentType::Gemini => vec![],
|
||||
ConnectorAgentType::Custom => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a discovered Zed agent into a `ConnectorConfig` for the runtime.
|
||||
///
|
||||
/// Only creates connectors for agents with resolved binary paths (typically
|
||||
/// registry agents whose binaries have been downloaded by Zed).
|
||||
///
|
||||
/// Returns `None` if the agent has no binary path.
|
||||
pub fn zed_agent_to_connector_config(agent: &dirigent_zed::ZedAgent) -> Option<ConnectorConfig> {
|
||||
let binary_path = agent.binary_path.as_ref()?;
|
||||
|
||||
// Map agent names to proper types. Handles both Zed settings keys
|
||||
// (e.g. "claude-acp") and external_agents directory names (e.g. "claude-agent-acp").
|
||||
let name_lower = agent.name.to_lowercase();
|
||||
let (default_title, default_icon, agent_type): (&str, &str, ConnectorAgentType) =
|
||||
if name_lower.contains("claude") {
|
||||
("Claude (Zed)", "claude", ConnectorAgentType::Claude)
|
||||
} else if name_lower.contains("codex") {
|
||||
("Codex (Zed)", "codex", ConnectorAgentType::Codex)
|
||||
} else if name_lower.contains("gemini") {
|
||||
("Gemini (Zed)", "gemini", ConnectorAgentType::Gemini)
|
||||
} else {
|
||||
(agent.name.as_str(), "acp", ConnectorAgentType::Custom)
|
||||
};
|
||||
|
||||
// Use registry display name with "(Zed)" suffix when available, falling
|
||||
// back to the hardcoded title.
|
||||
let title: String = match agent.display_name.as_deref() {
|
||||
Some(display) => format!("{display} (Zed)"),
|
||||
None => default_title.to_string(),
|
||||
};
|
||||
|
||||
// Use the locally cached SVG icon path from the registry when available,
|
||||
// otherwise fall back to the built-in icon name.
|
||||
let icon: String = match agent.icon_local_path.as_ref() {
|
||||
Some(path) => path.to_string_lossy().to_string(),
|
||||
None => default_icon.to_string(),
|
||||
};
|
||||
|
||||
let features = default_features_for_agent(&agent_type);
|
||||
|
||||
// Build a proper AcpConfig with stdio transport pointing to the Zed-managed binary.
|
||||
let env: Vec<(String, String)> = agent
|
||||
.env_overrides
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
// Use args from registry metadata (e.g. ["--acp"]) when available.
|
||||
let args = if agent.args.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
agent.args.clone()
|
||||
};
|
||||
|
||||
let acp_config = AcpConfig {
|
||||
transport: TransportKind::Stdio {
|
||||
command: binary_path.to_string_lossy().to_string(),
|
||||
args,
|
||||
cwd: None,
|
||||
env,
|
||||
},
|
||||
protocol_version: 1,
|
||||
cwd: ".".to_string(),
|
||||
retry: Default::default(),
|
||||
embedding: EmbeddingConfig::default(),
|
||||
default_ownership: Default::default(),
|
||||
acp_log_dir: None,
|
||||
agent_type,
|
||||
};
|
||||
|
||||
let params = match serde_json::to_value(&acp_config) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
agent = %agent.name,
|
||||
error = %e,
|
||||
"Failed to serialize AcpConfig for Zed agent"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(ConnectorConfig {
|
||||
id: None,
|
||||
kind: ConnectorKind::Acp,
|
||||
owner: None,
|
||||
title: Some(title),
|
||||
working_directory: None,
|
||||
params,
|
||||
icon_path: Some(icon),
|
||||
show_type_overlay: false,
|
||||
supported_features: features,
|
||||
tool_configuration: None,
|
||||
plugin_assignments: vec![],
|
||||
use_in_new_projects: true,
|
||||
source: None,
|
||||
zed_agent_name: Some(agent.name.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh binary paths for Zed-sourced connectors in the config.
|
||||
///
|
||||
/// When Zed upgrades agent binaries in the background, the binary path changes
|
||||
/// (e.g. new version directory). This function re-detects the current binary
|
||||
/// paths from Zed installations and updates any connector that has a
|
||||
/// `zed_agent_name` set.
|
||||
///
|
||||
/// Returns the number of connectors updated.
|
||||
pub fn refresh_zed_connector_binaries(config: &mut CoreConfig) -> usize {
|
||||
let installations = dirigent_zed::detect_installations();
|
||||
if installations.is_empty() {
|
||||
debug!("No Zed installations detected, skipping binary refresh");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Build a map of agent_name -> latest binary path from all Zed installations.
|
||||
let mut agent_binaries: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
for installation in &installations {
|
||||
for agent in &installation.agents {
|
||||
if let Some(ref binary_path) = agent.binary_path {
|
||||
agent_binaries
|
||||
.insert(agent.name.clone(), binary_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut updated = 0usize;
|
||||
|
||||
for connector in &mut config.connectors {
|
||||
let zed_name = match connector.zed_agent_name.as_deref() {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let new_binary = match agent_binaries.get(zed_name) {
|
||||
Some(b) => b.clone(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Parse current ACP config to check the existing binary path.
|
||||
let mut acp_config: AcpConfig = match serde_json::from_value(connector.params.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let current_command = match &acp_config.transport {
|
||||
TransportKind::Stdio { command, .. } => command.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if current_command == new_binary {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update the transport command to the new binary path.
|
||||
match &mut acp_config.transport {
|
||||
TransportKind::Stdio { command, .. } => {
|
||||
info!(
|
||||
zed_agent = %zed_name,
|
||||
old = %current_command,
|
||||
new = %new_binary,
|
||||
"Updating Zed connector binary path"
|
||||
);
|
||||
*command = new_binary;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
// Re-serialize back into params.
|
||||
match serde_json::to_value(&acp_config) {
|
||||
Ok(v) => {
|
||||
connector.params = v;
|
||||
updated += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
zed_agent = %zed_name,
|
||||
error = %e,
|
||||
"Failed to re-serialize AcpConfig after binary update"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
info!(count = updated, "Updated Zed connector binary paths");
|
||||
}
|
||||
|
||||
updated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::CoreConfig;
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_to_connector_config_no_binary() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: None,
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
assert!(zed_agent_to_connector_config(&agent).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_to_connector_config_with_binary() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/usr/local/bin/claude-acp")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(config.kind, ConnectorKind::Acp);
|
||||
assert_eq!(config.title.as_deref(), Some("Claude (Zed)"));
|
||||
assert_eq!(config.icon_path.as_deref(), Some("claude"));
|
||||
assert_eq!(config.source, None);
|
||||
|
||||
// Verify the params contain a proper AcpConfig
|
||||
let acp_config: AcpConfig = serde_json::from_value(config.params).unwrap();
|
||||
match &acp_config.transport {
|
||||
TransportKind::Stdio { command, .. } => {
|
||||
assert_eq!(command, "/usr/local/bin/claude-acp");
|
||||
}
|
||||
_ => panic!("Expected stdio transport"),
|
||||
}
|
||||
assert_eq!(acp_config.agent_type, ConnectorAgentType::Claude);
|
||||
assert_eq!(config.supported_features, vec!["cancellation", "session_resume", "session_list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_to_connector_config_with_env() {
|
||||
let mut env = std::collections::HashMap::new();
|
||||
env.insert(
|
||||
"CLAUDE_CODE_EXECUTABLE".to_string(),
|
||||
"/usr/bin/claude".to_string(),
|
||||
);
|
||||
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/binary")),
|
||||
env_overrides: env,
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
let acp_config: AcpConfig = serde_json::from_value(config.params).unwrap();
|
||||
match &acp_config.transport {
|
||||
TransportKind::Stdio { env, .. } => {
|
||||
assert!(env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "CLAUDE_CODE_EXECUTABLE" && v == "/usr/bin/claude"));
|
||||
}
|
||||
_ => panic!("Expected stdio transport"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_to_connector_config_unknown_agent() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "my-custom-agent".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Custom,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/custom")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(config.title.as_deref(), Some("my-custom-agent"));
|
||||
assert_eq!(config.icon_path.as_deref(), Some("acp"));
|
||||
|
||||
let acp_config: AcpConfig = serde_json::from_value(config.params).unwrap();
|
||||
assert_eq!(acp_config.agent_type, ConnectorAgentType::Custom);
|
||||
assert!(config.supported_features.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_codex() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "codex-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/codex")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(config.title.as_deref(), Some("Codex (Zed)"));
|
||||
assert_eq!(config.icon_path.as_deref(), Some("codex"));
|
||||
|
||||
let acp_config: AcpConfig = serde_json::from_value(config.params).unwrap();
|
||||
assert_eq!(acp_config.agent_type, ConnectorAgentType::Codex);
|
||||
assert_eq!(config.supported_features, vec!["session_resume"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_gemini() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "gemini".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/gemini")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(config.title.as_deref(), Some("Gemini (Zed)"));
|
||||
assert_eq!(config.icon_path.as_deref(), Some("gemini"));
|
||||
|
||||
let acp_config: AcpConfig = serde_json::from_value(config.params).unwrap();
|
||||
assert_eq!(acp_config.agent_type, ConnectorAgentType::Gemini);
|
||||
assert!(config.supported_features.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dismissed_zed_agent_title_matches_generated_config() {
|
||||
// Verify that a dismissed title like "Claude (Zed)" matches the title
|
||||
// generated by zed_agent_to_connector_config for a claude-acp agent
|
||||
let mut core_config = CoreConfig::default();
|
||||
core_config
|
||||
.dismissed_zed_agents
|
||||
.push("Claude (Zed)".to_string());
|
||||
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/usr/local/bin/claude-acp")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let connector_config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(connector_config.title.as_deref(), Some("Claude (Zed)"));
|
||||
// The dismissed list should contain the generated title
|
||||
assert!(core_config
|
||||
.dismissed_zed_agents
|
||||
.contains(connector_config.title.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dismissed_list_does_not_block_other_agents() {
|
||||
// Dismissing Claude should not block Codex or Gemini
|
||||
let mut core_config = CoreConfig::default();
|
||||
core_config
|
||||
.dismissed_zed_agents
|
||||
.push("Claude (Zed)".to_string());
|
||||
|
||||
let codex_title = "Codex (Zed)".to_string();
|
||||
let gemini_title = "Gemini (Zed)".to_string();
|
||||
|
||||
assert!(!core_config.dismissed_zed_agents.contains(&codex_title));
|
||||
assert!(!core_config.dismissed_zed_agents.contains(&gemini_title));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dismissed_zed_agents_serde_roundtrip() {
|
||||
// Verify dismissed_zed_agents survives serialization/deserialization
|
||||
let mut core_config = CoreConfig::default();
|
||||
core_config
|
||||
.dismissed_zed_agents
|
||||
.push("Claude (Zed)".to_string());
|
||||
core_config
|
||||
.dismissed_zed_agents
|
||||
.push("Gemini (Zed)".to_string());
|
||||
|
||||
let json = serde_json::to_string(&core_config).unwrap();
|
||||
let deserialized: CoreConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.dismissed_zed_agents.len(), 2);
|
||||
assert!(deserialized
|
||||
.dismissed_zed_agents
|
||||
.contains(&"Claude (Zed)".to_string()));
|
||||
assert!(deserialized
|
||||
.dismissed_zed_agents
|
||||
.contains(&"Gemini (Zed)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dismissed_zed_agents_empty_not_serialized() {
|
||||
// With skip_serializing_if = "Vec::is_empty", empty list should not appear in JSON
|
||||
let core_config = CoreConfig::default();
|
||||
let json = serde_json::to_string(&core_config).unwrap();
|
||||
assert!(
|
||||
!json.contains("dismissed_zed_agents"),
|
||||
"Empty dismissed_zed_agents should be omitted from serialization"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dismissed_zed_agents_deserialized_from_missing_field() {
|
||||
// Old config files without dismissed_zed_agents should still deserialize
|
||||
// thanks to #[serde(default)]
|
||||
let json = r#"{"project_dir":".","connectors":[]}"#;
|
||||
let config: CoreConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.dismissed_zed_agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_name_is_set() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/usr/local/bin/claude-acp")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(config.zed_agent_name.as_deref(), Some("claude-acp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_name_preserves_original_name() {
|
||||
// The zed_agent_name should be the exact Zed agent name, not the display title
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-agent-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/binary")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(config.title.as_deref(), Some("Claude (Zed)"));
|
||||
assert_eq!(config.zed_agent_name.as_deref(), Some("claude-agent-acp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_agent_name_serde_roundtrip() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "codex".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/codex")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
assert!(json.contains("\"zed_agent_name\":\"codex\""));
|
||||
let deserialized: ConnectorConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.zed_agent_name.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_zed_connector_has_no_zed_agent_name() {
|
||||
// ConnectorConfig created via template should not have zed_agent_name
|
||||
let config = ConnectorConfig::default();
|
||||
assert!(config.zed_agent_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enriched_display_name_used_in_title() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/claude")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: Some("Claude Agent".to_string()),
|
||||
description: Some("ACP wrapper for Anthropic's Claude".to_string()),
|
||||
args: Vec::new(),
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
// Title should use the registry display name with "(Zed)" suffix.
|
||||
assert_eq!(config.title.as_deref(), Some("Claude Agent (Zed)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enriched_args_passed_to_transport() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "auggie".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/auggie")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: Some("Auggie CLI".to_string()),
|
||||
description: None,
|
||||
args: vec!["--acp".to_string()],
|
||||
icon_local_path: None,
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
let acp_config: AcpConfig = serde_json::from_value(config.params).unwrap();
|
||||
match &acp_config.transport {
|
||||
TransportKind::Stdio { args, .. } => {
|
||||
assert_eq!(args, &["--acp"]);
|
||||
}
|
||||
_ => panic!("Expected stdio transport"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enriched_icon_path_used() {
|
||||
let agent = dirigent_zed::ZedAgent {
|
||||
name: "claude-acp".to_string(),
|
||||
agent_type: dirigent_zed::AgentServerType::Registry,
|
||||
binary_path: Some(std::path::PathBuf::from("/path/to/claude")),
|
||||
env_overrides: std::collections::HashMap::new(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
args: Vec::new(),
|
||||
icon_local_path: Some(std::path::PathBuf::from("/icons/claude-acp.svg")),
|
||||
icon_url: None,
|
||||
};
|
||||
|
||||
let config = zed_agent_to_connector_config(&agent).unwrap();
|
||||
assert_eq!(config.icon_path.as_deref(), Some("/icons/claude-acp.svg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! SharingBus: single-producer, many-subscriber event multiplexer with
|
||||
//! subscriber-side filtering performed by a worker task. See
|
||||
//! docs/plans/2026-04-21-archivist-phase4-design.md §1.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! - One internal `tokio::sync::broadcast::Sender<BusEvent>` feeds a single
|
||||
//! worker task. The worker iterates `Vec<SubscriberSlot>` (behind `RwLock`),
|
||||
//! filter-matches each slot, and `try_send`s the event onto each slot's
|
||||
//! `mpsc::Sender<BusEvent>`.
|
||||
//! - Slow subscribers drop their own events at their mpsc (counted in the
|
||||
//! slot's `lagged` atomic). The bus-internal broadcast channel never drops
|
||||
//! due to a slow subscriber — only due to the broadcast lag contract, which
|
||||
//! we log and continue.
|
||||
//! - `SessionRegistered` events late-bind `(connector_id, native_session_id) ->
|
||||
//! scroll_id` via a small cache consulted on every publish.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_protocol::streaming::{BusEvent, EventFilter};
|
||||
pub use dirigent_protocol::streaming::BusReceiver;
|
||||
use dirigent_protocol::Event;
|
||||
|
||||
const BUS_INTERNAL_CAPACITY: usize = 1024;
|
||||
const SUBSCRIBER_QUEUE_DEFAULT: usize = 256;
|
||||
|
||||
/// Single-producer, many-subscriber event multiplexer.
|
||||
///
|
||||
/// Subscribers see a `mpsc::Receiver<BusEvent>` that only yields events
|
||||
/// matching their `EventFilter`. Filtering happens inside a single worker
|
||||
/// task, so the cost per event is O(n_subscribers) regardless of publisher
|
||||
/// count. Slow subscribers lose events at their own mpsc, not at the bus.
|
||||
pub struct SharingBus {
|
||||
publish_tx: broadcast::Sender<BusEvent>,
|
||||
subscribers: Arc<RwLock<Vec<SubscriberSlot>>>,
|
||||
scroll_id_cache: Arc<RwLock<HashMap<(String, String), Uuid>>>,
|
||||
next_id: Arc<AtomicU64>,
|
||||
_worker: JoinHandle<()>,
|
||||
}
|
||||
|
||||
struct SubscriberSlot {
|
||||
id: u64,
|
||||
filter: EventFilter,
|
||||
sender: mpsc::Sender<BusEvent>,
|
||||
lagged: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl SharingBus {
|
||||
/// Construct a new bus and spawn its dispatch worker.
|
||||
pub fn new() -> Arc<Self> {
|
||||
let (publish_tx, publish_rx) = broadcast::channel(BUS_INTERNAL_CAPACITY);
|
||||
let subscribers: Arc<RwLock<Vec<SubscriberSlot>>> = Arc::new(RwLock::new(Vec::new()));
|
||||
let scroll_id_cache: Arc<RwLock<HashMap<(String, String), Uuid>>> =
|
||||
Arc::new(RwLock::new(HashMap::new()));
|
||||
let next_id = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let worker = tokio::spawn(run_worker(publish_rx, Arc::clone(&subscribers)));
|
||||
|
||||
Arc::new(Self {
|
||||
publish_tx,
|
||||
subscribers,
|
||||
scroll_id_cache,
|
||||
next_id,
|
||||
_worker: worker,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a `BusEvent` to all matching subscribers.
|
||||
///
|
||||
/// This method also performs two side-effects on the scroll-id cache:
|
||||
///
|
||||
/// 1. If the wrapped event is `Event::SessionRegistered`, the binding
|
||||
/// `(connector_id, session_id) -> scroll_id` is inserted into the
|
||||
/// cache, and the current event's `routing.scroll_id` is set so the
|
||||
/// binding event itself carries its own scroll_id downstream.
|
||||
/// 2. If the event's `routing.scroll_id` is absent but it carries both a
|
||||
/// `connector_id` and `native_session_id`, the cache is consulted to
|
||||
/// late-bind `scroll_id` before broadcasting.
|
||||
pub async fn publish(&self, mut bus_event: BusEvent) {
|
||||
// (2) Late-bind scroll_id from cache if we can, BEFORE the possibly
|
||||
// more specific (1) handling overrides it. This is a no-op for
|
||||
// SessionRegistered (its scroll_id is always populated in (1)).
|
||||
if bus_event.routing.scroll_id.is_none() {
|
||||
if let (Some(cid), Some(nsid)) = (
|
||||
bus_event.routing.connector_id.as_ref(),
|
||||
bus_event.routing.native_session_id.as_ref(),
|
||||
) {
|
||||
let cache = self.scroll_id_cache.read().await;
|
||||
if let Some(uuid) = cache.get(&(cid.clone(), nsid.clone())) {
|
||||
bus_event.routing.scroll_id = Some(*uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (1) If the wrapped event is SessionRegistered, populate the cache
|
||||
// and set scroll_id on the event itself.
|
||||
if let Event::SessionRegistered {
|
||||
connector_id,
|
||||
session_id,
|
||||
scroll_id,
|
||||
} = bus_event.event.as_ref()
|
||||
{
|
||||
match Uuid::parse_str(scroll_id) {
|
||||
Ok(uuid) => {
|
||||
self.scroll_id_cache
|
||||
.write()
|
||||
.await
|
||||
.insert((connector_id.clone(), session_id.clone()), uuid);
|
||||
bus_event.routing.scroll_id = Some(uuid);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
connector_id = %connector_id,
|
||||
session_id = %session_id,
|
||||
scroll_id = %scroll_id,
|
||||
error = %e,
|
||||
"SessionRegistered carried an unparseable scroll_id; skipping late-bind cache insert",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No subscribers is not an error — ignore the Result.
|
||||
let _ = self.publish_tx.send(bus_event);
|
||||
}
|
||||
|
||||
/// Subscribe to every event on the bus.
|
||||
pub async fn subscribe_all(&self) -> BusReceiver {
|
||||
self.subscribe_filtered(EventFilter::All, SUBSCRIBER_QUEUE_DEFAULT)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Subscribe to events that match `filter`. `queue_capacity` caps the
|
||||
/// buffered events between the worker and the caller's `recv()`.
|
||||
pub async fn subscribe_filtered(
|
||||
&self,
|
||||
filter: EventFilter,
|
||||
queue_capacity: usize,
|
||||
) -> BusReceiver {
|
||||
let (tx, rx) = mpsc::channel(queue_capacity);
|
||||
let lagged = Arc::new(AtomicU64::new(0));
|
||||
// Relaxed ordering is sufficient: subscriber IDs are only compared for
|
||||
// equality with other IDs issued by this same bus; there is no
|
||||
// cross-thread ordering dependency on this counter.
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
self.subscribers.write().await.push(SubscriberSlot {
|
||||
id,
|
||||
filter,
|
||||
sender: tx,
|
||||
lagged: Arc::clone(&lagged),
|
||||
});
|
||||
BusReceiver { id, rx, lagged }
|
||||
}
|
||||
|
||||
/// Remove a subscriber by id. Idempotent.
|
||||
pub async fn unsubscribe(&self, id: u64) {
|
||||
self.subscribers.write().await.retain(|s| s.id != id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_worker(
|
||||
mut rx: broadcast::Receiver<BusEvent>,
|
||||
subscribers: Arc<RwLock<Vec<SubscriberSlot>>>,
|
||||
) {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
let mut closed_ids: Vec<u64> = Vec::new();
|
||||
{
|
||||
let subs = subscribers.read().await;
|
||||
for slot in subs.iter() {
|
||||
if !slot.filter.matches(&evt) {
|
||||
continue;
|
||||
}
|
||||
match slot.sender.try_send(evt.clone()) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
slot.lagged.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(
|
||||
subscriber_id = slot.id,
|
||||
"bus subscriber queue full; dropping event"
|
||||
);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
closed_ids.push(slot.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !closed_ids.is_empty() {
|
||||
subscribers
|
||||
.write()
|
||||
.await
|
||||
.retain(|s| !closed_ids.contains(&s.id));
|
||||
debug!(removed = closed_ids.len(), "GC'd closed subscriber slots");
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(skipped = n, "SharingBus internal broadcast lagged");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!("SharingBus worker exiting (sender closed)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
use dirigent_protocol::streaming::{BusEvent, EventKind, EventOrigin, EventRouting};
|
||||
use dirigent_protocol::Event;
|
||||
|
||||
/// Build a minimal `BusEvent` for tests. Uses `Event::Connected` as payload
|
||||
/// unless a specific event is needed for late-bind checks.
|
||||
fn make_event(
|
||||
scroll_id: Option<Uuid>,
|
||||
connector_uid: Option<Uuid>,
|
||||
connector_id: Option<String>,
|
||||
native_session_id: Option<String>,
|
||||
kind: EventKind,
|
||||
event: Event,
|
||||
) -> BusEvent {
|
||||
BusEvent {
|
||||
routing: EventRouting {
|
||||
scroll_id,
|
||||
connector_uid,
|
||||
connector_id,
|
||||
native_session_id,
|
||||
kind,
|
||||
},
|
||||
origin: EventOrigin::Runtime,
|
||||
event: Arc::new(event),
|
||||
}
|
||||
}
|
||||
|
||||
// 1. subscribe_all + publish: one event round-trips to receiver.
|
||||
#[tokio::test]
|
||||
async fn subscribe_all_receives_published_event() {
|
||||
let bus = SharingBus::new();
|
||||
let mut recv = bus.subscribe_all().await;
|
||||
|
||||
let ev = make_event(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
EventKind::System,
|
||||
Event::Connected,
|
||||
);
|
||||
bus.publish(ev).await;
|
||||
|
||||
let got = timeout(Duration::from_millis(200), recv.rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for event")
|
||||
.expect("channel closed unexpectedly");
|
||||
|
||||
match got.event.as_ref() {
|
||||
Event::Connected => {}
|
||||
other => panic!("expected Event::Connected, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. ConnectorUid filter: matching UID passes, other UID skipped.
|
||||
#[tokio::test]
|
||||
async fn connector_uid_filter_only_forwards_matching_events() {
|
||||
let bus = SharingBus::new();
|
||||
let target = Uuid::new_v4();
|
||||
let other = Uuid::new_v4();
|
||||
|
||||
let mut recv = bus
|
||||
.subscribe_filtered(EventFilter::ConnectorUid(target), 16)
|
||||
.await;
|
||||
|
||||
// Publish one matching and one non-matching event.
|
||||
let ev_match = make_event(
|
||||
None,
|
||||
Some(target),
|
||||
None,
|
||||
None,
|
||||
EventKind::System,
|
||||
Event::Connected,
|
||||
);
|
||||
let ev_other = make_event(
|
||||
None,
|
||||
Some(other),
|
||||
None,
|
||||
None,
|
||||
EventKind::System,
|
||||
Event::Connected,
|
||||
);
|
||||
bus.publish(ev_match).await;
|
||||
bus.publish(ev_other).await;
|
||||
|
||||
// First recv returns the matching event.
|
||||
let got = timeout(Duration::from_millis(200), recv.rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for first event")
|
||||
.expect("channel closed unexpectedly");
|
||||
assert_eq!(got.routing.connector_uid, Some(target));
|
||||
|
||||
// Second recv must time out — no other matching event was published.
|
||||
let result = timeout(Duration::from_millis(100), recv.rx.recv()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected no further events, got: {:?}",
|
||||
result.ok().flatten().map(|e| e.routing.connector_uid)
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Queue full = lagged counter increments, first event still delivered.
|
||||
#[tokio::test]
|
||||
async fn full_queue_increments_lagged_counter() {
|
||||
let bus = SharingBus::new();
|
||||
// Capacity 1 — only one event can be buffered before try_send fails.
|
||||
let mut recv = bus.subscribe_filtered(EventFilter::All, 1).await;
|
||||
|
||||
// Publish 5 events without draining.
|
||||
for _ in 0..5 {
|
||||
let ev = make_event(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
EventKind::System,
|
||||
Event::Connected,
|
||||
);
|
||||
bus.publish(ev).await;
|
||||
}
|
||||
|
||||
// Give the worker a chance to process all 5.
|
||||
for _ in 0..10 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
|
||||
// First event is still in the queue.
|
||||
let first = timeout(Duration::from_millis(200), recv.rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for first event")
|
||||
.expect("channel closed unexpectedly");
|
||||
match first.event.as_ref() {
|
||||
Event::Connected => {}
|
||||
other => panic!("expected Event::Connected, got {:?}", other),
|
||||
}
|
||||
|
||||
// At minimum 4 events were dropped (5 published, 1 fit).
|
||||
let lagged = recv.lagged.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
lagged >= 4,
|
||||
"expected lagged >= 4 after publishing 5 events to a capacity-1 queue, got {}",
|
||||
lagged
|
||||
);
|
||||
}
|
||||
|
||||
// 4. scroll_id late-bind: SessionRegistered populates cache; subsequent
|
||||
// events with matching (connector_id, native_session_id) get their
|
||||
// scroll_id filled in before dispatch.
|
||||
#[tokio::test]
|
||||
async fn session_registered_populates_cache_and_late_binds_subsequent_events() {
|
||||
let bus = SharingBus::new();
|
||||
let scroll = Uuid::new_v4();
|
||||
|
||||
// Subscriber filters on ScrollId(scroll). It should see:
|
||||
// - the SessionRegistered event (bus sets its own scroll_id at publish)
|
||||
// - a follow-up event with (connector_id="c", native_session_id="s")
|
||||
// that had no scroll_id on entry (late-bound from the cache).
|
||||
let mut recv = bus
|
||||
.subscribe_filtered(EventFilter::ScrollId(scroll), 16)
|
||||
.await;
|
||||
|
||||
// --- publish SessionRegistered (binding event) ---
|
||||
let reg_event = Event::SessionRegistered {
|
||||
connector_id: "c".to_string(),
|
||||
session_id: "s".to_string(),
|
||||
scroll_id: scroll.to_string(),
|
||||
};
|
||||
// We pass through the routing fields the producer would populate.
|
||||
// `scroll_id` starts as None; publish() sets it from the event payload.
|
||||
let reg_bus = make_event(
|
||||
None,
|
||||
None,
|
||||
Some("c".to_string()),
|
||||
Some("s".to_string()),
|
||||
EventKind::SessionLifecycle,
|
||||
reg_event,
|
||||
);
|
||||
bus.publish(reg_bus).await;
|
||||
|
||||
let got1 = timeout(Duration::from_millis(200), recv.rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for SessionRegistered")
|
||||
.expect("channel closed unexpectedly");
|
||||
assert!(matches!(
|
||||
got1.event.as_ref(),
|
||||
Event::SessionRegistered { .. }
|
||||
));
|
||||
assert_eq!(got1.routing.scroll_id, Some(scroll));
|
||||
|
||||
// --- publish a follow-up event with no scroll_id but matching
|
||||
// connector_id + native_session_id ---
|
||||
let follow_up = make_event(
|
||||
None,
|
||||
None,
|
||||
Some("c".to_string()),
|
||||
Some("s".to_string()),
|
||||
EventKind::System,
|
||||
Event::Connected,
|
||||
);
|
||||
bus.publish(follow_up).await;
|
||||
|
||||
let got2 = timeout(Duration::from_millis(200), recv.rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for late-bound follow-up")
|
||||
.expect("channel closed unexpectedly");
|
||||
assert_eq!(
|
||||
got2.routing.scroll_id,
|
||||
Some(scroll),
|
||||
"follow-up event should have had scroll_id late-bound from the cache"
|
||||
);
|
||||
assert!(matches!(got2.event.as_ref(), Event::Connected));
|
||||
}
|
||||
|
||||
// 5. Dropped receiver is GC'd after the next publish.
|
||||
#[tokio::test]
|
||||
async fn closed_receiver_slot_is_reaped_on_next_publish() {
|
||||
let bus = SharingBus::new();
|
||||
|
||||
// Subscribe, then immediately drop the receiver — simulates a caller
|
||||
// that forgets (or skips) `unsubscribe()`.
|
||||
let recv = bus.subscribe_all().await;
|
||||
drop(recv);
|
||||
|
||||
// Sanity check: slot is present before GC.
|
||||
assert_eq!(bus.subscribers.read().await.len(), 1);
|
||||
|
||||
// Publish one event; the worker encounters TrySendError::Closed and
|
||||
// schedules the slot for removal.
|
||||
let ev = make_event(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
EventKind::System,
|
||||
Event::Connected,
|
||||
);
|
||||
bus.publish(ev).await;
|
||||
|
||||
// Give the worker a moment to process and GC.
|
||||
for _ in 0..10 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
assert_eq!(
|
||||
bus.subscribers.read().await.len(),
|
||||
0,
|
||||
"closed subscriber slot should have been GC'd after publish"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! `[[streams]]` TOML config block parsed from `dirigent.toml`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use dirigent_protocol::streaming::StreamScope;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct StreamsConfig {
|
||||
#[serde(default, rename = "streams")]
|
||||
pub entries: Vec<StreamConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct StreamConfig {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String, // "matrix" | "langfuse" | ...
|
||||
pub scope: StreamScope,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_params")]
|
||||
pub params: toml::Value, // type-specific
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool { true }
|
||||
|
||||
fn default_params() -> toml::Value { toml::Value::Table(toml::map::Map::new()) }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
const FULL_TOML: &str = r#"
|
||||
[[streams]]
|
||||
name = "matrix-main"
|
||||
type = "matrix"
|
||||
enabled = true
|
||||
|
||||
[streams.scope]
|
||||
kind = "session"
|
||||
scroll_id = "01985d00-0000-7000-8000-000000000000"
|
||||
|
||||
[streams.params]
|
||||
homeserver_url = "https://matrix.org"
|
||||
room_id = "!abc:matrix.org"
|
||||
"#;
|
||||
|
||||
const MINIMAL_TOML: &str = r#"
|
||||
[[streams]]
|
||||
name = "minimal"
|
||||
type = "langfuse"
|
||||
|
||||
[streams.scope]
|
||||
kind = "archive_wide"
|
||||
acknowledged = false
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn round_trip_full() {
|
||||
let cfg: StreamsConfig = toml::from_str(FULL_TOML).expect("parse failed");
|
||||
|
||||
assert_eq!(cfg.entries.len(), 1);
|
||||
let entry = &cfg.entries[0];
|
||||
assert_eq!(entry.name, "matrix-main");
|
||||
assert_eq!(entry.kind, "matrix");
|
||||
assert!(entry.enabled);
|
||||
|
||||
let expected_id = Uuid::parse_str("01985d00-0000-7000-8000-000000000000").unwrap();
|
||||
match &entry.scope {
|
||||
StreamScope::Session { scroll_id } => {
|
||||
assert_eq!(*scroll_id, expected_id);
|
||||
}
|
||||
other => panic!("expected Session scope, got {:?}", other),
|
||||
}
|
||||
|
||||
let params = entry.params.as_table().expect("params should be a table");
|
||||
assert_eq!(
|
||||
params.get("homeserver_url").and_then(|v| v.as_str()),
|
||||
Some("https://matrix.org")
|
||||
);
|
||||
assert_eq!(
|
||||
params.get("room_id").and_then(|v| v.as_str()),
|
||||
Some("!abc:matrix.org")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_enabled_when_omitted() {
|
||||
let cfg: StreamsConfig = toml::from_str(MINIMAL_TOML).expect("parse failed");
|
||||
assert_eq!(cfg.entries.len(), 1);
|
||||
let entry = &cfg.entries[0];
|
||||
assert_eq!(entry.name, "minimal");
|
||||
assert!(entry.enabled, "enabled should default to true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_is_valid() {
|
||||
let cfg: StreamsConfig = toml::from_str("").expect("empty parse failed");
|
||||
assert!(cfg.entries.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Maps a `StreamConfig`'s `type` string to a concrete [`SessionStream`].
|
||||
//!
|
||||
//! Stream implementations register themselves with a
|
||||
//! [`StreamFactoryRegistry`] at boot so the runtime can construct streams
|
||||
//! from `[[streams]]` config blocks without knowing every concrete type up
|
||||
//! front. This is the stream-side analogue of the archivist's
|
||||
//! `BackendRegistry` / `BackendFactory` pattern (Phase 3).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use dirigent_protocol::streaming::SessionStream;
|
||||
|
||||
use super::config::StreamConfig;
|
||||
|
||||
/// Builds a concrete [`SessionStream`] from a `StreamConfig`.
|
||||
///
|
||||
/// Each implementation advertises a single `kind` (matching the `type`
|
||||
/// string in the TOML config). The registry routes config blocks to the
|
||||
/// matching factory at boot.
|
||||
#[async_trait]
|
||||
pub trait StreamFactory: Send + Sync {
|
||||
/// The `type` discriminator in `[[streams]]` — e.g. `"matrix"`,
|
||||
/// `"langfuse"`. Must be unique across all registered factories.
|
||||
fn kind(&self) -> &'static str;
|
||||
|
||||
/// Build a running stream from its config. Implementations are
|
||||
/// expected to read `cfg.params` (type-specific TOML table), establish
|
||||
/// any transport, and return an [`Arc<dyn SessionStream>`] ready to
|
||||
/// receive events.
|
||||
async fn build(&self, cfg: &StreamConfig) -> Result<Arc<dyn SessionStream>, StreamBuildError>;
|
||||
}
|
||||
|
||||
/// Lookup table of `StreamFactory` implementations keyed by `kind()`.
|
||||
///
|
||||
/// Populate this at startup (typically once, on the main thread) before
|
||||
/// handing it off to the runtime. The registry is cheap to clone via
|
||||
/// `Arc` at the call site.
|
||||
#[derive(Default)]
|
||||
pub struct StreamFactoryRegistry {
|
||||
factories: HashMap<&'static str, Arc<dyn StreamFactory>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for StreamFactoryRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("StreamFactoryRegistry")
|
||||
.field("kinds", &self.factories.keys().collect::<Vec<_>>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamFactoryRegistry {
|
||||
/// Construct an empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a factory. Consumes and returns `self` so callers can
|
||||
/// chain `.register(...)` calls at startup.
|
||||
pub fn register<F: StreamFactory + 'static>(mut self, f: F) -> Self {
|
||||
self.factories.insert(f.kind(), Arc::new(f));
|
||||
self
|
||||
}
|
||||
|
||||
/// Look up a factory by its `kind` discriminator, or `None` if no
|
||||
/// factory for that kind has been registered.
|
||||
pub fn get(&self, kind: &str) -> Option<&Arc<dyn StreamFactory>> {
|
||||
self.factories.get(kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised while building a `SessionStream` from its config.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StreamBuildError {
|
||||
/// No factory is registered for `cfg.kind`.
|
||||
#[error("unknown kind: {0}")]
|
||||
UnknownKind(String),
|
||||
/// Config was structurally valid TOML but semantically invalid
|
||||
/// (missing required field, enum out of range, etc).
|
||||
#[error("config: {0}")]
|
||||
Config(String),
|
||||
/// The factory accepted the config but the transport refused to come
|
||||
/// up (network error, auth failure, …).
|
||||
#[error("transport: {0}")]
|
||||
Transport(String),
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use dirigent_protocol::streaming::{
|
||||
BusEvent, SessionStream, StreamKind, StreamOutcome, StreamScope, StreamSummary,
|
||||
};
|
||||
|
||||
struct DummyStream;
|
||||
|
||||
#[async_trait]
|
||||
impl SessionStream for DummyStream {
|
||||
fn summary(&self) -> StreamSummary {
|
||||
StreamSummary {
|
||||
name: "dummy".to_string(),
|
||||
kind: StreamKind::Custom,
|
||||
target: "dummy".to_string(),
|
||||
active_since: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
fn scope(&self) -> StreamScope {
|
||||
StreamScope::ArchiveWide { acknowledged: false }
|
||||
}
|
||||
async fn on_event(&self, _event: &BusEvent) -> StreamOutcome {
|
||||
StreamOutcome::Ok
|
||||
}
|
||||
async fn shutdown(&self) {}
|
||||
}
|
||||
|
||||
struct DummyFactory;
|
||||
|
||||
#[async_trait]
|
||||
impl StreamFactory for DummyFactory {
|
||||
fn kind(&self) -> &'static str {
|
||||
"dummy"
|
||||
}
|
||||
async fn build(
|
||||
&self,
|
||||
_cfg: &StreamConfig,
|
||||
) -> Result<Arc<dyn SessionStream>, StreamBuildError> {
|
||||
Ok(Arc::new(DummyStream))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_lookup() {
|
||||
let reg = StreamFactoryRegistry::new().register(DummyFactory);
|
||||
assert!(reg.get("dummy").is_some());
|
||||
assert!(reg.get("missing").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Consecutive-failure health drift for streams (K=5 threshold).
|
||||
//!
|
||||
//! Mirrors the archivist's drift logic but tracks a single stream's
|
||||
//! outcomes. Re-exports the shared `HealthStatus` enum from the
|
||||
//! archivist's backend module to avoid duplication.
|
||||
|
||||
pub use dirigent_archivist::backend::HealthStatus;
|
||||
|
||||
/// Number of consecutive failures before a stream drifts from `Degraded`
|
||||
/// to `Unavailable`. Matches the archivist's backend drift threshold.
|
||||
pub const FAILURE_THRESHOLD: u32 = 5;
|
||||
|
||||
/// Update health state after a successful event delivery.
|
||||
/// Resets consecutive-failure counter; lifts Degraded → Healthy.
|
||||
pub fn record_success(health: &mut HealthStatus, consecutive_failures: &mut u32) {
|
||||
*consecutive_failures = 0;
|
||||
if matches!(health, HealthStatus::Degraded { .. }) {
|
||||
*health = HealthStatus::Healthy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update health state after a failed event delivery.
|
||||
/// Increments counter; drifts Healthy → Degraded → Unavailable at K=5.
|
||||
pub fn record_failure(
|
||||
health: &mut HealthStatus,
|
||||
consecutive_failures: &mut u32,
|
||||
reason: String,
|
||||
) {
|
||||
*consecutive_failures += 1;
|
||||
if *consecutive_failures >= FAILURE_THRESHOLD {
|
||||
*health = HealthStatus::Unavailable {
|
||||
reason: format!("{} failures: {}", *consecutive_failures, reason),
|
||||
};
|
||||
} else {
|
||||
*health = HealthStatus::Degraded { reason };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn record_success_promotes_degraded_to_healthy() {
|
||||
let mut health = HealthStatus::Degraded {
|
||||
reason: "earlier hiccup".to_string(),
|
||||
};
|
||||
let mut counter: u32 = 3;
|
||||
record_success(&mut health, &mut counter);
|
||||
assert_eq!(counter, 0);
|
||||
assert_eq!(health, HealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_success_keeps_healthy_and_resets_counter() {
|
||||
let mut health = HealthStatus::Healthy;
|
||||
let mut counter: u32 = 2;
|
||||
record_success(&mut health, &mut counter);
|
||||
assert_eq!(counter, 0);
|
||||
assert_eq!(health, HealthStatus::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_success_does_not_rescue_unavailable() {
|
||||
// The archivist rule is: once Unavailable, only operational events
|
||||
// rescue. Success on a stream does clear the counter but we leave
|
||||
// the final clearing decision to the caller; document the current
|
||||
// behaviour: we only downgrade Degraded, not Unavailable.
|
||||
let mut health = HealthStatus::Unavailable {
|
||||
reason: "still broken".to_string(),
|
||||
};
|
||||
let mut counter: u32 = 7;
|
||||
record_success(&mut health, &mut counter);
|
||||
assert_eq!(counter, 0);
|
||||
assert!(matches!(health, HealthStatus::Unavailable { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_failure_once_moves_healthy_to_degraded() {
|
||||
let mut health = HealthStatus::Healthy;
|
||||
let mut counter: u32 = 0;
|
||||
record_failure(&mut health, &mut counter, "boom".to_string());
|
||||
assert_eq!(counter, 1);
|
||||
match health {
|
||||
HealthStatus::Degraded { reason } => assert_eq!(reason, "boom"),
|
||||
other => panic!("expected Degraded, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_failure_five_times_drifts_to_unavailable() {
|
||||
let mut health = HealthStatus::Healthy;
|
||||
let mut counter: u32 = 0;
|
||||
for i in 0..5 {
|
||||
record_failure(&mut health, &mut counter, format!("err-{i}"));
|
||||
}
|
||||
assert_eq!(counter, 5);
|
||||
match health {
|
||||
HealthStatus::Unavailable { reason } => {
|
||||
assert!(reason.contains("5 failures"), "reason: {reason}");
|
||||
}
|
||||
other => panic!("expected Unavailable after 5 failures, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_failure_from_degraded_drifts_to_unavailable_at_threshold() {
|
||||
let mut health = HealthStatus::Degraded {
|
||||
reason: "early".to_string(),
|
||||
};
|
||||
let mut counter: u32 = 4;
|
||||
record_failure(&mut health, &mut counter, "final".to_string());
|
||||
assert_eq!(counter, 5);
|
||||
assert!(matches!(health, HealthStatus::Unavailable { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! `MatrixFactory`: build a Matrix [`SessionStream`] from a `[[streams]]`
|
||||
//! config block.
|
||||
//!
|
||||
//! This is the first stream-side factory wired for the Phase 4 migration
|
||||
//! (Task 18). The factory lives in `dirigent_core` rather than
|
||||
//! `dirigent_matrix` because `StreamFactory` is defined here and
|
||||
//! `dirigent_core` already depends on `dirigent_matrix` — putting it in
|
||||
//! `dirigent_matrix` would create a cycle.
|
||||
//!
|
||||
//! ## Scope
|
||||
//!
|
||||
//! The factory's responsibility is narrow: parse `cfg.params`, resolve
|
||||
//! the target Matrix room via a running `MatrixService`, and construct a
|
||||
//! `MatrixSessionShare` configured for the stream path (no legacy
|
||||
//! forwarder task). Command-proxy wiring (Matrix → Dirigent
|
||||
//! `ConnectorCommand::SendMessage`) remains in
|
||||
//! `CoreRuntime::create_matrix_share` for now; a follow-up will extend
|
||||
//! the factory to cover that path.
|
||||
//!
|
||||
//! ## Config shape
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[streams]]
|
||||
//! name = "matrix-main"
|
||||
//! type = "matrix"
|
||||
//!
|
||||
//! [streams.scope]
|
||||
//! kind = "session"
|
||||
//! scroll_id = "01985d00-..."
|
||||
//!
|
||||
//! [streams.params]
|
||||
//! connector_id = "opencode-1" # dirigent connector key
|
||||
//! session_id = "native-abc123" # native connector session id
|
||||
//! room_id = "!abc:matrix.org" # pre-existing room to attach to
|
||||
//! homeserver_url = "https://matrix.org" # informational (service already knows)
|
||||
//! ```
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
|
||||
use dirigent_protocol::streaming::{SessionStream, StreamScope};
|
||||
|
||||
use super::config::StreamConfig;
|
||||
use super::factory::{StreamBuildError, StreamFactory};
|
||||
|
||||
/// Stream-side factory for Matrix. See module docs for the expected
|
||||
/// TOML shape.
|
||||
pub struct MatrixFactory {
|
||||
service: Arc<dirigent_matrix::MatrixService>,
|
||||
}
|
||||
|
||||
impl MatrixFactory {
|
||||
/// Build a factory bound to a running `MatrixService`. The service
|
||||
/// is expected to be logged in and sync-started by the time
|
||||
/// `build()` is called; if it isn't, `build()` returns
|
||||
/// `StreamBuildError::Transport`.
|
||||
pub fn new(service: Arc<dirigent_matrix::MatrixService>) -> Self {
|
||||
Self { service }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MatrixStreamParams {
|
||||
/// Dirigent connector id that owns the session being bridged.
|
||||
connector_id: String,
|
||||
/// Native connector session id.
|
||||
session_id: String,
|
||||
/// Matrix room id — must be a pre-existing room the bot can access.
|
||||
/// Room creation is still handled by
|
||||
/// `CoreRuntime::create_matrix_share` until the factory path is
|
||||
/// expanded to cover it.
|
||||
room_id: String,
|
||||
/// Informational; the logged-in `MatrixService` is the authority on
|
||||
/// which homeserver to talk to. Accepted so configs can be
|
||||
/// self-documenting and round-trip through TOML.
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
homeserver_url: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StreamFactory for MatrixFactory {
|
||||
fn kind(&self) -> &'static str {
|
||||
"matrix"
|
||||
}
|
||||
|
||||
async fn build(
|
||||
&self,
|
||||
cfg: &StreamConfig,
|
||||
) -> Result<Arc<dyn SessionStream>, StreamBuildError> {
|
||||
// Scope must be Session; Matrix shares are intrinsically
|
||||
// per-session bi-directional bridges.
|
||||
let scroll_id = match &cfg.scope {
|
||||
StreamScope::Session { scroll_id } => *scroll_id,
|
||||
other => {
|
||||
return Err(StreamBuildError::Config(format!(
|
||||
"matrix stream requires scope.kind = \"session\", got {:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Parse type-specific params.
|
||||
let params: MatrixStreamParams = cfg
|
||||
.params
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| {
|
||||
StreamBuildError::Config(format!(
|
||||
"matrix stream '{}': invalid params: {}",
|
||||
cfg.name, e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Look up the room via the service. We intentionally don't
|
||||
// create or join rooms here — the room must already exist.
|
||||
// Creation remains the responsibility of
|
||||
// `CoreRuntime::create_matrix_share`.
|
||||
let room = match self.service.room_by_id(¶ms.room_id).await {
|
||||
Ok(Some(room)) => room,
|
||||
Ok(None) => {
|
||||
return Err(StreamBuildError::Transport(format!(
|
||||
"matrix stream '{}': room '{}' not found on client \
|
||||
— ensure the bot has joined it",
|
||||
cfg.name, params.room_id
|
||||
)));
|
||||
}
|
||||
Err(dirigent_matrix::MatrixError::NotLoggedIn) => {
|
||||
return Err(StreamBuildError::Transport(
|
||||
"matrix service is not logged in; cannot build stream"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Err(dirigent_matrix::MatrixError::Config(msg)) => {
|
||||
return Err(StreamBuildError::Config(format!(
|
||||
"matrix stream '{}': {}",
|
||||
cfg.name, msg
|
||||
)));
|
||||
}
|
||||
Err(other) => {
|
||||
return Err(StreamBuildError::Transport(format!(
|
||||
"matrix stream '{}': {}",
|
||||
cfg.name, other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Construct the share for the stream path (no legacy forwarder
|
||||
// task). We drop the command receiver on the floor here — the
|
||||
// Matrix → Dirigent direction is not covered by this factory
|
||||
// yet; see the follow-up TODO in the module docs.
|
||||
let (share, _command_rx) = dirigent_matrix::MatrixSessionShare::new_for_stream(
|
||||
params.connector_id,
|
||||
params.session_id,
|
||||
scroll_id,
|
||||
params.room_id,
|
||||
room,
|
||||
);
|
||||
|
||||
Ok(Arc::new(share) as Arc<dyn SessionStream>)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn factory_kind_is_matrix() {
|
||||
// The factory's `kind()` is static and doesn't require a running
|
||||
// MatrixService to read — covered by a minimal construction
|
||||
// check in the integration test suite.
|
||||
fn assert_is_factory<F: StreamFactory>(_: &F) {}
|
||||
|
||||
// We can't easily build a MatrixService in a unit test (it needs
|
||||
// an Account + data dir + SQLite store). The full smoke test
|
||||
// lives in `packages/dirigent_matrix/tests/factory_test.rs` and
|
||||
// the cross-crate registry test in
|
||||
// `packages/dirigent_core/tests/matrix_migration_test.rs`.
|
||||
//
|
||||
// This module-local test exists only to assert that the impl
|
||||
// block type-checks against the `StreamFactory` trait bound.
|
||||
fn _compile_check(f: &MatrixFactory) {
|
||||
assert_is_factory(f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_stream_params_deserialise_ok() {
|
||||
let toml_str = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
room_id = "!foo:example.com"
|
||||
homeserver_url = "https://matrix.org"
|
||||
"#;
|
||||
let p: MatrixStreamParams = toml::from_str(toml_str).expect("parse");
|
||||
assert_eq!(p.connector_id, "opencode-1");
|
||||
assert_eq!(p.session_id, "native-abc");
|
||||
assert_eq!(p.room_id, "!foo:example.com");
|
||||
assert_eq!(p.homeserver_url.as_deref(), Some("https://matrix.org"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_stream_params_reject_missing_required() {
|
||||
// Missing room_id should fail.
|
||||
let toml_str = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
"#;
|
||||
let err: Result<MatrixStreamParams, _> = toml::from_str(toml_str);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Test-only `SessionStream` implementation for integration tests.
|
||||
//!
|
||||
//! Records every received `BusEvent` into an in-memory buffer; can be
|
||||
//! configured to fail the next N events to exercise health drift paths.
|
||||
|
||||
#![cfg(any(test, feature = "test-utils"))]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use dirigent_protocol::streaming::{
|
||||
BusEvent, SessionStream, StreamError, StreamKind, StreamOutcome, StreamScope, StreamSummary,
|
||||
};
|
||||
|
||||
/// In-memory `SessionStream` used in integration tests.
|
||||
pub struct MockStream {
|
||||
scope: StreamScope,
|
||||
name: String,
|
||||
pub received: Arc<Mutex<Vec<BusEvent>>>,
|
||||
pub fail_remaining: Arc<AtomicU32>,
|
||||
pub shutdown_called: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl MockStream {
|
||||
pub fn new(name: impl Into<String>, scope: StreamScope) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
scope,
|
||||
name: name.into(),
|
||||
received: Arc::new(Mutex::new(Vec::new())),
|
||||
fail_remaining: Arc::new(AtomicU32::new(0)),
|
||||
shutdown_called: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure the next N `on_event` calls to return `StreamOutcome::Failed`.
|
||||
pub fn fail_next(&self, n: u32) {
|
||||
self.fail_remaining.store(n, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn received_count(&self) -> usize {
|
||||
self.received.lock().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn was_shutdown(&self) -> bool {
|
||||
*self.shutdown_called.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SessionStream for MockStream {
|
||||
fn summary(&self) -> StreamSummary {
|
||||
StreamSummary {
|
||||
name: self.name.clone(),
|
||||
kind: StreamKind::Custom,
|
||||
target: "mock".into(),
|
||||
active_since: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(&self) -> StreamScope {
|
||||
self.scope.clone()
|
||||
}
|
||||
|
||||
async fn on_event(&self, event: &BusEvent) -> StreamOutcome {
|
||||
if self.fail_remaining.load(Ordering::Relaxed) > 0 {
|
||||
self.fail_remaining.fetch_sub(1, Ordering::Relaxed);
|
||||
return StreamOutcome::Failed(StreamError::Rejected("mock fail".into()));
|
||||
}
|
||||
self.received.lock().unwrap().push(event.clone());
|
||||
StreamOutcome::Ok
|
||||
}
|
||||
|
||||
async fn shutdown(&self) {
|
||||
*self.shutdown_called.lock().unwrap() = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! SharingBus, StreamRegistry, and replay. See docs/plans/2026-04-21-archivist-phase4-design.md.
|
||||
|
||||
pub mod bus;
|
||||
pub mod config;
|
||||
pub mod factory;
|
||||
pub mod health;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod matrix;
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub mod mock;
|
||||
pub mod registry;
|
||||
pub mod replay;
|
||||
|
||||
pub use bus::{BusReceiver, SharingBus};
|
||||
pub use config::{StreamConfig, StreamsConfig};
|
||||
pub use factory::{StreamBuildError, StreamFactory, StreamFactoryRegistry};
|
||||
pub use health::HealthStatus;
|
||||
#[cfg(feature = "server")]
|
||||
pub use matrix::MatrixFactory;
|
||||
pub use registry::{StreamId, StreamInfo, StreamRegistration, StreamRegistry};
|
||||
pub use replay::{ReplayError, ReplayOptions, ReplayReport, ReplaySpeed};
|
||||
#[cfg(any(test, feature = "test-utils"))]
|
||||
pub use mock::MockStream;
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Owns all active streams.
|
||||
//!
|
||||
//! Populated at boot from `[[streams]]` config and at runtime via
|
||||
//! [`StreamRegistry::attach`]. Each attached stream gets:
|
||||
//!
|
||||
//! - a bus subscription with an [`EventFilter`] derived from its scope,
|
||||
//! - a dedicated worker task that drives `SessionStream::on_event`,
|
||||
//! - a per-stream [`HealthStatus`] that drifts on consecutive failures
|
||||
//! (see [`super::health`]).
|
||||
//!
|
||||
//! The worker is cancellable via a one-shot `mpsc::Sender<()>` on the
|
||||
//! registration so [`detach`](StreamRegistry::detach) can stop delivery
|
||||
//! deterministically before invoking `SessionStream::shutdown`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_protocol::streaming::{
|
||||
BusReceiver, EventFilter, SessionStream, StreamOutcome, StreamScope, StreamSummary,
|
||||
};
|
||||
|
||||
use super::bus::SharingBus;
|
||||
use super::health::{HealthStatus, record_failure, record_success};
|
||||
|
||||
/// Per-subscriber queue capacity for a stream's bus subscription. Matches
|
||||
/// the default used by `SharingBus::subscribe_all`.
|
||||
const STREAM_QUEUE_CAPACITY: usize = 256;
|
||||
|
||||
/// Identifier of a registered stream. Opaque wrapper around a `Uuid` so
|
||||
/// that callers can't confuse stream ids with scroll/connector ids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct StreamId(pub Uuid);
|
||||
|
||||
/// Full registration record for a live stream.
|
||||
///
|
||||
/// Held inside the registry behind an `Arc`; `detach` returns the Arc so
|
||||
/// callers can inspect the final health state or await the worker handle
|
||||
/// if they wish. Most fields are `Arc`s so the worker task and the
|
||||
/// registry share state without serialising on a single lock.
|
||||
pub struct StreamRegistration {
|
||||
pub id: StreamId,
|
||||
pub name: String,
|
||||
pub stream: Arc<dyn SessionStream>,
|
||||
pub scope: StreamScope,
|
||||
pub enabled: bool,
|
||||
pub health: Arc<RwLock<HealthStatus>>,
|
||||
/// Number of consecutive delivery failures; drives the K=5 drift to
|
||||
/// `Unavailable`. Stored atomic so the worker can update without
|
||||
/// taking the health lock on every success.
|
||||
pub consecutive_failures: Arc<AtomicU32>,
|
||||
pub worker: JoinHandle<()>,
|
||||
pub stop_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
/// Snapshot view of a registered stream. Returned by
|
||||
/// [`StreamRegistry::list`] for telemetry / UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamInfo {
|
||||
pub id: StreamId,
|
||||
pub name: String,
|
||||
pub summary: StreamSummary,
|
||||
pub scope: StreamScope,
|
||||
pub enabled: bool,
|
||||
pub health: HealthStatus,
|
||||
/// Current consecutive-failure count (mirrors
|
||||
/// `StreamRegistration::consecutive_failures` at read time).
|
||||
pub lagged_count: u64,
|
||||
}
|
||||
|
||||
/// The live registry of all streams wired to a [`SharingBus`].
|
||||
pub struct StreamRegistry {
|
||||
bus: Arc<SharingBus>,
|
||||
regs: RwLock<Vec<Arc<StreamRegistration>>>,
|
||||
}
|
||||
|
||||
impl StreamRegistry {
|
||||
/// Build an empty registry bound to `bus`.
|
||||
pub fn new(bus: Arc<SharingBus>) -> Self {
|
||||
Self {
|
||||
bus,
|
||||
regs: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a running stream.
|
||||
///
|
||||
/// Subscribes to the bus with a filter derived from `stream.scope()`,
|
||||
/// spawns a worker task that ferries events into `on_event`, and
|
||||
/// stores a registration with fresh health state.
|
||||
pub async fn attach(&self, name: String, stream: Arc<dyn SessionStream>) -> StreamId {
|
||||
let id = StreamId(Uuid::now_v7());
|
||||
let scope = stream.scope();
|
||||
let filter = scope_to_filter(&scope);
|
||||
let bus_rx = self
|
||||
.bus
|
||||
.subscribe_filtered(filter, STREAM_QUEUE_CAPACITY)
|
||||
.await;
|
||||
|
||||
let (stop_tx, stop_rx) = mpsc::channel(1);
|
||||
let health = Arc::new(RwLock::new(HealthStatus::Healthy));
|
||||
let failures = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let stream_for_worker = Arc::clone(&stream);
|
||||
let health_for_worker = Arc::clone(&health);
|
||||
let failures_for_worker = Arc::clone(&failures);
|
||||
let name_for_worker = name.clone();
|
||||
|
||||
let worker = tokio::spawn(run_stream_worker(
|
||||
name_for_worker,
|
||||
bus_rx,
|
||||
stream_for_worker,
|
||||
health_for_worker,
|
||||
failures_for_worker,
|
||||
stop_rx,
|
||||
));
|
||||
|
||||
let reg = Arc::new(StreamRegistration {
|
||||
id,
|
||||
name,
|
||||
stream,
|
||||
scope,
|
||||
enabled: true,
|
||||
health,
|
||||
consecutive_failures: failures,
|
||||
worker,
|
||||
stop_tx,
|
||||
});
|
||||
self.regs.write().await.push(reg);
|
||||
id
|
||||
}
|
||||
|
||||
/// Detach a stream. Signals the worker to exit, then invokes
|
||||
/// `SessionStream::shutdown`. Returns the registration if the stream
|
||||
/// was found, or `None` if the id was already detached.
|
||||
pub async fn detach(&self, id: StreamId) -> Option<Arc<StreamRegistration>> {
|
||||
let mut regs = self.regs.write().await;
|
||||
let idx = regs.iter().position(|r| r.id == id)?;
|
||||
let reg = regs.remove(idx);
|
||||
drop(regs);
|
||||
|
||||
// Best-effort stop: if the channel is already closed (worker panicked)
|
||||
// we still want to run shutdown.
|
||||
let _ = reg.stop_tx.send(()).await;
|
||||
reg.stream.shutdown().await;
|
||||
Some(reg)
|
||||
}
|
||||
|
||||
/// Look up a live stream by id.
|
||||
pub async fn get_stream(&self, id: StreamId) -> Option<Arc<dyn SessionStream>> {
|
||||
self.regs
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| Arc::clone(&r.stream))
|
||||
}
|
||||
|
||||
/// Snapshot every registered stream. Clones the underlying health
|
||||
/// value so the returned `Vec` is safe to hand across async tasks
|
||||
/// without holding any locks.
|
||||
pub async fn list(&self) -> Vec<StreamInfo> {
|
||||
let regs = self.regs.read().await;
|
||||
let mut out = Vec::with_capacity(regs.len());
|
||||
for r in regs.iter() {
|
||||
let health = r.health.read().await.clone();
|
||||
out.push(StreamInfo {
|
||||
id: r.id,
|
||||
name: r.name.clone(),
|
||||
summary: r.stream.summary(),
|
||||
scope: r.scope.clone(),
|
||||
enabled: r.enabled,
|
||||
health,
|
||||
lagged_count: r.consecutive_failures.load(Ordering::Relaxed) as u64,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a declarative [`StreamScope`] into the subscriber-side
|
||||
/// [`EventFilter`] applied on the bus.
|
||||
fn scope_to_filter(scope: &StreamScope) -> EventFilter {
|
||||
match scope {
|
||||
StreamScope::Session { scroll_id } => EventFilter::ScrollId(*scroll_id),
|
||||
StreamScope::Connector { connector_uid } => EventFilter::ConnectorUid(*connector_uid),
|
||||
StreamScope::ArchiveWide { .. } => EventFilter::All,
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker loop: pulls events from the bus subscription, forwards them to
|
||||
/// the stream, and updates health state on every outcome.
|
||||
async fn run_stream_worker(
|
||||
name: String,
|
||||
mut rx: BusReceiver,
|
||||
stream: Arc<dyn SessionStream>,
|
||||
health: Arc<RwLock<HealthStatus>>,
|
||||
failures: Arc<AtomicU32>,
|
||||
mut stop_rx: mpsc::Receiver<()>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = stop_rx.recv() => {
|
||||
return;
|
||||
}
|
||||
maybe_evt = rx.rx.recv() => {
|
||||
let Some(evt) = maybe_evt else {
|
||||
// Bus hung up — registry should detach but we can exit
|
||||
// here regardless.
|
||||
return;
|
||||
};
|
||||
match stream.on_event(&evt).await {
|
||||
StreamOutcome::Ok | StreamOutcome::Skipped => {
|
||||
let mut h = health.write().await;
|
||||
let mut counter = failures.load(Ordering::Relaxed);
|
||||
record_success(&mut h, &mut counter);
|
||||
failures.store(counter, Ordering::Relaxed);
|
||||
}
|
||||
StreamOutcome::Failed(err) => {
|
||||
let reason = err.to_string();
|
||||
warn!(stream = %name, error = %reason, "stream rejected event");
|
||||
let mut h = health.write().await;
|
||||
let mut counter = failures.load(Ordering::Relaxed);
|
||||
record_failure(&mut h, &mut counter, reason);
|
||||
failures.store(counter, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Replay: reads a session from the archive and dispatches synthetic
|
||||
//! `BusEvent`s with `EventOrigin::Replay` directly to a target stream,
|
||||
//! bypassing the `SharingBus`.
|
||||
//!
|
||||
//! Consumed by `CoreRuntime::replay_session_to_stream` (task 16). This
|
||||
//! module intentionally exposes a free function that takes
|
||||
//! `&Archivist`, `scroll_id`, `Arc<dyn SessionStream>`, and `ReplayOptions`
|
||||
//! so it can be unit-tested without a full runtime.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::error::ArchivistError;
|
||||
use dirigent_archivist::types::MessageRecord;
|
||||
use dirigent_protocol::{
|
||||
Event, Message, MessagePart, MessageRole, MessageStatus,
|
||||
streaming::{BusEvent, EventOrigin, EventRouting, SessionStream, StreamOutcome},
|
||||
};
|
||||
|
||||
/// Options controlling a replay pass.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReplayOptions {
|
||||
/// When true and the session is an AcpConnection, meta-events are read from
|
||||
/// the archive (currently only counted — rendering meta events as
|
||||
/// `BusEvent`s is out of scope for Phase 4).
|
||||
pub include_meta_events: bool,
|
||||
/// Pace events in real time (sleep between consecutive timestamps) or emit
|
||||
/// as fast as the target stream can consume.
|
||||
pub speed: ReplaySpeed,
|
||||
}
|
||||
|
||||
impl Default for ReplayOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
include_meta_events: false,
|
||||
speed: ReplaySpeed::AsFastAsPossible,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls inter-event pacing during replay.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReplaySpeed {
|
||||
/// Sleep the wall-clock delta between consecutive message timestamps.
|
||||
Realtime,
|
||||
/// Emit events as fast as the stream can consume.
|
||||
AsFastAsPossible,
|
||||
}
|
||||
|
||||
/// Outcome of a replay pass.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ReplayReport {
|
||||
/// Total events dispatched to the stream (includes failed attempts).
|
||||
pub events_sent: usize,
|
||||
/// Events the stream rejected (`StreamOutcome::Failed`).
|
||||
pub failures: usize,
|
||||
/// Wall-clock duration of the replay in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Errors raised by `replay_session_to_stream` itself. Stream-side failures are
|
||||
/// counted in `ReplayReport::failures` rather than propagated, so one bad event
|
||||
/// doesn't abort the replay.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReplayError {
|
||||
/// The archive has no session with the given scroll id.
|
||||
#[error("session not found: {0}")]
|
||||
SessionNotFound(Uuid),
|
||||
/// Archivist returned a non-SessionUnknown error (I/O, decoding, etc).
|
||||
#[error("archivist: {0}")]
|
||||
Archivist(String),
|
||||
}
|
||||
|
||||
/// Replay a session's archived messages to a single `SessionStream`.
|
||||
///
|
||||
/// Reads metadata + messages from `archivist`, synthesises a `BusEvent` per
|
||||
/// message with `EventOrigin::Replay { replay_id }`, and dispatches directly
|
||||
/// to the target stream. The `SharingBus` is not involved; live events remain
|
||||
/// unaffected.
|
||||
///
|
||||
/// The function continues on stream failures and records the count in the
|
||||
/// returned `ReplayReport`; only unrecoverable archive errors propagate.
|
||||
pub async fn replay_session_to_stream(
|
||||
archivist: &Archivist,
|
||||
scroll_id: Uuid,
|
||||
stream: Arc<dyn SessionStream>,
|
||||
opts: ReplayOptions,
|
||||
) -> Result<ReplayReport, ReplayError> {
|
||||
let start = std::time::Instant::now();
|
||||
let replay_id = Uuid::new_v4();
|
||||
|
||||
// Load metadata. Translate the archivist's typed `SessionUnknown` into
|
||||
// the replay-level `SessionNotFound` variant; everything else becomes
|
||||
// `Archivist(_)` so callers can distinguish "missing" from "broken".
|
||||
let metadata = archivist
|
||||
.get_session_metadata(scroll_id, None)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ArchivistError::SessionUnknown(id) => ReplayError::SessionNotFound(id),
|
||||
other => ReplayError::Archivist(other.to_string()),
|
||||
})?;
|
||||
|
||||
let messages = archivist
|
||||
.get_messages(scroll_id, None)
|
||||
.await
|
||||
.map_err(|e| ReplayError::Archivist(e.to_string()))?;
|
||||
|
||||
let connector_uid = Some(metadata.connector_uid);
|
||||
let native_session_id = metadata.native_session_id.clone();
|
||||
// We do not persist the orchestrator-side `connector_id` string in session
|
||||
// metadata; the native session id is the best reversible handle we have.
|
||||
let connector_id = native_session_id.clone().unwrap_or_default();
|
||||
|
||||
let mut events_sent = 0usize;
|
||||
let mut failures = 0usize;
|
||||
let mut prev_ts: Option<chrono::DateTime<chrono::Utc>> = None;
|
||||
|
||||
for record in messages {
|
||||
if matches!(opts.speed, ReplaySpeed::Realtime) {
|
||||
if let Some(prev) = prev_ts {
|
||||
let delta = record.ts.signed_duration_since(prev);
|
||||
if let Ok(d) = delta.to_std() {
|
||||
// Cap per-step sleep at 1h to avoid pathological archives
|
||||
// where a session sat idle for days.
|
||||
if d > Duration::from_millis(0) && d < Duration::from_secs(3600) {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
prev_ts = Some(record.ts);
|
||||
}
|
||||
|
||||
let message = message_from_record(&record, native_session_id.as_deref());
|
||||
let event = Event::MessageCompleted {
|
||||
connector_id: connector_id.clone(),
|
||||
message,
|
||||
};
|
||||
|
||||
let mut routing = EventRouting::derive(&event, connector_uid, &connector_id);
|
||||
// `derive()` leaves scroll_id=None (the bus cache normally fills it in).
|
||||
// During replay we have the authoritative scroll_id up front.
|
||||
routing.scroll_id = Some(scroll_id);
|
||||
|
||||
let bus_event = BusEvent {
|
||||
routing,
|
||||
origin: EventOrigin::Replay { replay_id },
|
||||
event: Arc::new(event),
|
||||
};
|
||||
|
||||
match stream.on_event(&bus_event).await {
|
||||
StreamOutcome::Ok | StreamOutcome::Skipped => {
|
||||
events_sent += 1;
|
||||
}
|
||||
StreamOutcome::Failed(_err) => {
|
||||
failures += 1;
|
||||
events_sent += 1; // count attempted regardless
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.include_meta_events {
|
||||
// Meta-events exist only on AcpConnection sessions; the read is
|
||||
// cheap and idempotent, so we don't gate on `metadata.kind`. Render-
|
||||
// as-BusEvent is out of scope for Phase 4 — we just probe the
|
||||
// archive so missing meta-event storage surfaces as a log line
|
||||
// here rather than later in the call chain.
|
||||
let _ = archivist.get_meta_events(scroll_id, None).await;
|
||||
}
|
||||
|
||||
Ok(ReplayReport {
|
||||
events_sent,
|
||||
failures,
|
||||
duration_ms: start.elapsed().as_millis() as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Synthesize a protocol `Message` from an archived `MessageRecord`.
|
||||
///
|
||||
/// The session_id we emit is the connector's native session id when known,
|
||||
/// falling back to the stringified scroll_id so downstream routing at least
|
||||
/// has a stable handle.
|
||||
fn message_from_record(record: &MessageRecord, native_session_id: Option<&str>) -> Message {
|
||||
Message {
|
||||
id: record.message_id.to_string(),
|
||||
session_id: native_session_id
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| record.session.to_string()),
|
||||
role: parse_role(&record.role),
|
||||
created_at: record.ts,
|
||||
content: content_parts_from_record(record),
|
||||
status: MessageStatus::Completed,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the archivist's stringly-typed role into the protocol enum.
|
||||
///
|
||||
/// `MessageRole` only has `User` and `Assistant` today; archived "system" /
|
||||
/// "tool" rows (which the protocol layer does not support) fall back to
|
||||
/// `User` rather than drop the message entirely. Lossy but preserves content.
|
||||
fn parse_role(role: &str) -> MessageRole {
|
||||
match role {
|
||||
"assistant" => MessageRole::Assistant,
|
||||
"user" => MessageRole::User,
|
||||
// Protocol has no System/Tool variant; surface these as user messages
|
||||
// so their content still reaches the stream.
|
||||
_ => MessageRole::User,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefer the archived structured `content_parts` (round-trips tool calls,
|
||||
/// code blocks, etc). Fall back to a single `Text` part built from the
|
||||
/// markdown rendering when parts are missing or fail to parse.
|
||||
fn content_parts_from_record(record: &MessageRecord) -> Vec<MessagePart> {
|
||||
if let Some(parts) = &record.content_parts {
|
||||
if let Ok(parsed) = serde_json::from_value::<Vec<MessagePart>>(parts.clone()) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
vec![MessagePart::Text {
|
||||
text: record.content_md.clone(),
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Tool configuration container for managing a set of [`ToolDirective`]s.
|
||||
//!
|
||||
//! A [`ToolConfiguration`] holds a flat list of directives and provides
|
||||
//! query helpers used by the ACP intercept layer, the UI, and session
|
||||
//! persistence (via metadata helpers).
|
||||
|
||||
use super::directive::{ToolDirective, ToolHandler};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Metadata key used for storing tool configuration in session metadata.
|
||||
const METADATA_KEY: &str = "tool_configuration";
|
||||
|
||||
/// A collection of tool directives that together define the tool routing
|
||||
/// policy for a session or connector.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use dirigent_core::tools::{ToolConfiguration, ToolDirective, ToolHandler};
|
||||
///
|
||||
/// let mut config = ToolConfiguration::new();
|
||||
/// config.set(ToolDirective::checked("shell_exec", ToolHandler::Deny));
|
||||
///
|
||||
/// assert!(config.should_intercept("shell_exec"));
|
||||
/// assert!(!config.should_intercept("read_file"));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct ToolConfiguration {
|
||||
/// The ordered list of tool directives.
|
||||
#[serde(default)]
|
||||
pub directives: Vec<ToolDirective>,
|
||||
}
|
||||
|
||||
impl ToolConfiguration {
|
||||
/// Create an empty configuration with no directives.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Look up the directive for a given tool by its ID.
|
||||
pub fn get(&self, tool_id: &str) -> Option<&ToolDirective> {
|
||||
self.directives.iter().find(|d| d.tool_id == tool_id)
|
||||
}
|
||||
|
||||
/// Look up the directive for a given tool by its ID (mutable).
|
||||
pub fn get_mut(&mut self, tool_id: &str) -> Option<&mut ToolDirective> {
|
||||
self.directives.iter_mut().find(|d| d.tool_id == tool_id)
|
||||
}
|
||||
|
||||
/// Insert or replace a directive. If a directive for the same `tool_id`
|
||||
/// already exists, it is replaced in place; otherwise the new directive
|
||||
/// is appended.
|
||||
pub fn set(&mut self, directive: ToolDirective) {
|
||||
if let Some(existing) = self
|
||||
.directives
|
||||
.iter_mut()
|
||||
.find(|d| d.tool_id == directive.tool_id)
|
||||
{
|
||||
*existing = directive;
|
||||
} else {
|
||||
self.directives.push(directive);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the tool with the given ID has a checked directive
|
||||
/// with a non-Agent handler, meaning the call should be intercepted.
|
||||
///
|
||||
/// Returns `false` for unknown tools (no directive) or passthrough
|
||||
/// directives.
|
||||
pub fn should_intercept(&self, tool_id: &str) -> bool {
|
||||
self.get(tool_id)
|
||||
.is_some_and(|d| d.checked && d.handler != ToolHandler::Agent)
|
||||
}
|
||||
|
||||
/// Returns the active handler for a tool, if it has a checked directive.
|
||||
///
|
||||
/// Returns `None` for unknown tools or passthrough directives.
|
||||
pub fn active_handler(&self, tool_id: &str) -> Option<&ToolHandler> {
|
||||
self.get(tool_id)
|
||||
.filter(|d| d.checked)
|
||||
.map(|d| &d.handler)
|
||||
}
|
||||
|
||||
/// Returns the tool IDs of all directives that are checked (intercepted).
|
||||
pub fn checked_tools(&self) -> Vec<&str> {
|
||||
self.directives
|
||||
.iter()
|
||||
.filter(|d| d.checked)
|
||||
.map(|d| d.tool_id.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -- Metadata helpers for session persistence --
|
||||
|
||||
/// Deserialize a `ToolConfiguration` from the `"tool_configuration"` key
|
||||
/// in a session metadata JSON value.
|
||||
///
|
||||
/// Returns `None` if the key is absent or the value cannot be deserialized.
|
||||
pub fn from_metadata(metadata: &serde_json::Value) -> Option<Self> {
|
||||
metadata
|
||||
.get(METADATA_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
}
|
||||
|
||||
/// Serialize this configuration into the `"tool_configuration"` key of
|
||||
/// a session metadata JSON object.
|
||||
///
|
||||
/// If `metadata` is not already an object, this is a no-op.
|
||||
pub fn to_metadata(&self, metadata: &mut serde_json::Value) {
|
||||
if let Some(obj) = metadata.as_object_mut() {
|
||||
if let Ok(value) = serde_json::to_value(self) {
|
||||
obj.insert(METADATA_KEY.to_string(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_config_no_interception() {
|
||||
let config = ToolConfiguration::new();
|
||||
assert!(!config.should_intercept("any_tool"));
|
||||
assert!(config.active_handler("any_tool").is_none());
|
||||
assert!(config.checked_tools().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_directive() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::passthrough("read_file"));
|
||||
|
||||
assert!(!config.should_intercept("read_file"));
|
||||
// active_handler returns None for passthrough (not checked)
|
||||
assert!(config.active_handler("read_file").is_none());
|
||||
assert!(config.checked_tools().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_directive_deny() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::checked("shell_exec", ToolHandler::Deny));
|
||||
|
||||
assert!(config.should_intercept("shell_exec"));
|
||||
assert_eq!(config.active_handler("shell_exec"), Some(&ToolHandler::Deny));
|
||||
assert_eq!(config.checked_tools(), vec!["shell_exec"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_directive_hide() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::checked("dangerous_tool", ToolHandler::Hide));
|
||||
|
||||
assert!(config.should_intercept("dangerous_tool"));
|
||||
assert_eq!(
|
||||
config.active_handler("dangerous_tool"),
|
||||
Some(&ToolHandler::Hide)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_directive_agent_not_intercepted() {
|
||||
// A checked directive with Agent handler should NOT be intercepted
|
||||
// (Agent means passthrough even when checked).
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::checked("file_write", ToolHandler::Agent));
|
||||
|
||||
assert!(!config.should_intercept("file_write"));
|
||||
assert_eq!(
|
||||
config.active_handler("file_write"),
|
||||
Some(&ToolHandler::Agent)
|
||||
);
|
||||
assert_eq!(config.checked_tools(), vec!["file_write"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_existing() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::checked("shell_exec", ToolHandler::Deny));
|
||||
assert!(config.should_intercept("shell_exec"));
|
||||
|
||||
// Replace with passthrough
|
||||
config.set(ToolDirective::passthrough("shell_exec"));
|
||||
assert!(!config.should_intercept("shell_exec"));
|
||||
|
||||
// Should still be exactly one directive
|
||||
assert_eq!(config.directives.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_tools_list() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::passthrough("read_file"));
|
||||
config.set(ToolDirective::checked("shell_exec", ToolHandler::Deny));
|
||||
config.set(ToolDirective::checked("file_write", ToolHandler::Editor));
|
||||
config.set(ToolDirective::passthrough("search"));
|
||||
|
||||
let checked = config.checked_tools();
|
||||
assert_eq!(checked.len(), 2);
|
||||
assert!(checked.contains(&"shell_exec"));
|
||||
assert!(checked.contains(&"file_write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_and_get_mut() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::passthrough("read_file"));
|
||||
|
||||
assert!(config.get("read_file").is_some());
|
||||
assert!(config.get("nonexistent").is_none());
|
||||
|
||||
// Mutate via get_mut
|
||||
if let Some(d) = config.get_mut("read_file") {
|
||||
d.checked = true;
|
||||
d.handler = ToolHandler::Dirigent;
|
||||
}
|
||||
assert!(config.should_intercept("read_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::passthrough("read_file"));
|
||||
config.set(ToolDirective::checked("shell_exec", ToolHandler::Deny));
|
||||
config.set(ToolDirective::checked(
|
||||
"custom_tool",
|
||||
ToolHandler::Plugin { name: "my_plugin".to_string() },
|
||||
));
|
||||
|
||||
let json = serde_json::to_string_pretty(&config).expect("serialize");
|
||||
let deserialized: ToolConfiguration =
|
||||
serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(config, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_plugin_variant() {
|
||||
let handler = ToolHandler::Plugin { name: "security_scanner".to_string() };
|
||||
let json = serde_json::to_string(&handler).expect("serialize");
|
||||
|
||||
// Verify the tagged representation
|
||||
assert!(json.contains("Plugin"));
|
||||
assert!(json.contains("security_scanner"));
|
||||
|
||||
let deserialized: ToolHandler = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(handler, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_metadata_roundtrip() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::checked("shell_exec", ToolHandler::Deny));
|
||||
config.set(ToolDirective::checked(
|
||||
"plugin_tool",
|
||||
ToolHandler::Plugin { name: "my_plugin".to_string() },
|
||||
));
|
||||
|
||||
// Write to metadata
|
||||
let mut metadata = serde_json::json!({ "other_key": "other_value" });
|
||||
config.to_metadata(&mut metadata);
|
||||
|
||||
// Read back from metadata
|
||||
let restored = ToolConfiguration::from_metadata(&metadata)
|
||||
.expect("should deserialize from metadata");
|
||||
assert_eq!(config, restored);
|
||||
|
||||
// Original metadata keys are preserved
|
||||
assert_eq!(metadata["other_key"], "other_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_metadata_missing_key() {
|
||||
let metadata = serde_json::json!({ "something_else": 42 });
|
||||
assert!(ToolConfiguration::from_metadata(&metadata).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_metadata_noop_on_non_object() {
|
||||
let mut config = ToolConfiguration::new();
|
||||
config.set(ToolDirective::passthrough("tool"));
|
||||
|
||||
// Attempting to write to a non-object value should be a no-op
|
||||
let mut metadata = serde_json::json!("not an object");
|
||||
config.to_metadata(&mut metadata);
|
||||
assert_eq!(metadata, serde_json::json!("not an object"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Tool directive types for controlling how individual tools are handled.
|
||||
//!
|
||||
//! A [`ToolDirective`] associates a tool (identified by its `tool_id` string,
|
||||
//! matching `ToolCall.tool_name` from `dirigent_protocol`) with a [`ToolHandler`]
|
||||
//! that determines how invocations of that tool should be routed.
|
||||
//!
|
||||
//! Directives come in two flavors:
|
||||
//! - **Passthrough** (`checked: false`) -- the tool call is forwarded to the
|
||||
//! agent without interception.
|
||||
//! - **Checked** (`checked: true`) -- the tool call is intercepted and routed
|
||||
//! to the specified [`ToolHandler`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Determines how a tool invocation is routed when intercepted.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ToolHandler {
|
||||
/// Let the agent handle the tool call natively (default / passthrough).
|
||||
Agent,
|
||||
/// Route the tool call to the editor (e.g., file writes shown in the UI).
|
||||
Editor,
|
||||
/// Deny the tool call entirely.
|
||||
Deny,
|
||||
/// Hide the tool from the agent's tool list.
|
||||
Hide,
|
||||
/// Handle the tool call inside Dirigent itself.
|
||||
Dirigent,
|
||||
/// Route to a named plugin handler.
|
||||
Plugin { name: String },
|
||||
}
|
||||
|
||||
impl Default for ToolHandler {
|
||||
fn default() -> Self {
|
||||
Self::Agent
|
||||
}
|
||||
}
|
||||
|
||||
/// A directive that binds a tool identifier to a handler configuration.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use dirigent_core::tools::{ToolDirective, ToolHandler};
|
||||
///
|
||||
/// // Create a passthrough directive (agent handles the tool natively)
|
||||
/// let passthrough = ToolDirective::passthrough("file_write");
|
||||
/// assert!(!passthrough.checked);
|
||||
///
|
||||
/// // Create a checked directive that denies the tool
|
||||
/// let denied = ToolDirective::checked("shell_exec", ToolHandler::Deny);
|
||||
/// assert!(denied.checked);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolDirective {
|
||||
/// Tool identifier -- matches `ToolCall.tool_name` from dirigent_protocol.
|
||||
pub tool_id: String,
|
||||
/// Whether this tool should be intercepted (`true`) or passed through (`false`).
|
||||
pub checked: bool,
|
||||
/// The handler to use when `checked` is `true`.
|
||||
pub handler: ToolHandler,
|
||||
}
|
||||
|
||||
impl ToolDirective {
|
||||
/// Create a passthrough directive that lets the agent handle the tool natively.
|
||||
pub fn passthrough(tool_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_id: tool_id.into(),
|
||||
checked: false,
|
||||
handler: ToolHandler::Agent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a checked directive that intercepts the tool and routes it to the
|
||||
/// given handler.
|
||||
pub fn checked(tool_id: impl Into<String>, handler: ToolHandler) -> Self {
|
||||
Self {
|
||||
tool_id: tool_id.into(),
|
||||
checked: true,
|
||||
handler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn passthrough_has_agent_handler() {
|
||||
let d = ToolDirective::passthrough("read_file");
|
||||
assert_eq!(d.tool_id, "read_file");
|
||||
assert!(!d.checked);
|
||||
assert_eq!(d.handler, ToolHandler::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_stores_handler() {
|
||||
let d = ToolDirective::checked("shell_exec", ToolHandler::Deny);
|
||||
assert_eq!(d.tool_id, "shell_exec");
|
||||
assert!(d.checked);
|
||||
assert_eq!(d.handler, ToolHandler::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_handler_is_agent() {
|
||||
assert_eq!(ToolHandler::default(), ToolHandler::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_all_variants() {
|
||||
let variants = vec![
|
||||
ToolHandler::Agent,
|
||||
ToolHandler::Editor,
|
||||
ToolHandler::Deny,
|
||||
ToolHandler::Hide,
|
||||
ToolHandler::Dirigent,
|
||||
ToolHandler::Plugin { name: "my_plugin".to_string() },
|
||||
];
|
||||
|
||||
for handler in variants {
|
||||
let json = serde_json::to_string(&handler).expect("serialize");
|
||||
let deserialized: ToolHandler = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(handler, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn serde_toml_roundtrip_all_variants() {
|
||||
// Regression test: adjacently-tagged enums with unit variants fail TOML serialization.
|
||||
// ToolHandler must use internally-tagged (#[serde(tag = "type")]) to work with TOML.
|
||||
let directives = vec![
|
||||
ToolDirective::passthrough("read_file"),
|
||||
ToolDirective::checked("shell_exec", ToolHandler::Deny),
|
||||
ToolDirective::checked("editor_write", ToolHandler::Editor),
|
||||
ToolDirective::checked("hidden_tool", ToolHandler::Hide),
|
||||
ToolDirective::checked("dirigent_tool", ToolHandler::Dirigent),
|
||||
ToolDirective::checked("plugin_tool", ToolHandler::Plugin { name: "my_plugin".to_string() }),
|
||||
];
|
||||
|
||||
// Wrap in a table so TOML can serialize (TOML needs a top-level table)
|
||||
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
||||
struct Wrapper {
|
||||
directives: Vec<ToolDirective>,
|
||||
}
|
||||
|
||||
let wrapper = Wrapper { directives };
|
||||
let toml_str = toml::to_string_pretty(&wrapper).expect("TOML serialize");
|
||||
let roundtripped: Wrapper = toml::from_str(&toml_str).expect("TOML deserialize");
|
||||
assert_eq!(wrapper, roundtripped);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Tool directive and configuration types.
|
||||
//!
|
||||
//! This module defines the domain types for controlling how individual tools
|
||||
//! are routed during an agent session. A tool can be passed through to the
|
||||
//! agent, intercepted by the editor, denied, hidden, handled by Dirigent
|
||||
//! itself, or delegated to a plugin.
|
||||
//!
|
||||
//! # Key types
|
||||
//!
|
||||
//! - [`ToolHandler`] -- Enum of routing targets for a tool call.
|
||||
//! - [`ToolDirective`] -- Binds a tool ID to a handler (passthrough or checked).
|
||||
//! - [`ToolConfiguration`] -- Collection of directives with query and
|
||||
//! persistence helpers.
|
||||
//!
|
||||
//! These types are **not** feature-gated and are available on WASM targets,
|
||||
//! since they are pure data types used by both the server runtime and the
|
||||
//! web UI.
|
||||
|
||||
pub mod configuration;
|
||||
pub mod directive;
|
||||
|
||||
// Re-export the main types at the module level for convenience.
|
||||
pub use configuration::ToolConfiguration;
|
||||
pub use directive::{ToolDirective, ToolHandler};
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Core types for the Dirigent runtime
|
||||
//!
|
||||
//! This module defines the fundamental types used throughout the dirigent_core
|
||||
//! system for connector management and orchestration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Re-export user types from dirigent_auth
|
||||
pub use dirigent_auth::{User, UserId, UserProfile};
|
||||
|
||||
/// Unique identifier for a connector instance
|
||||
///
|
||||
/// Each connector in the system has a unique ID that is used to reference it
|
||||
/// across API calls, events, and internal state management.
|
||||
pub type ConnectorId = String;
|
||||
|
||||
/// Type of connector
|
||||
///
|
||||
/// Identifies the underlying agent system or protocol that a connector bridges to.
|
||||
/// Each variant represents a different integration with an external agent provider.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum ConnectorKind {
|
||||
/// OpenCode.ai connector (REST + SSE)
|
||||
OpenCode,
|
||||
/// Agent-Client Protocol connector
|
||||
Acp,
|
||||
/// Mock connector for testing
|
||||
Mock,
|
||||
/// Acceptor for incoming connections (accepts inbound ACP connections)
|
||||
///
|
||||
/// Unlike other ConnectorKind variants which initiate outbound connections,
|
||||
/// Acceptor represents an entry point for incoming connections from external
|
||||
/// ACP clients. Sessions from Acceptors are routed to other connectors for processing.
|
||||
Acceptor,
|
||||
/// Gateway connector for handling messages locally
|
||||
///
|
||||
/// The Gateway connector handles messages locally with configurable behavior
|
||||
/// including echo mode and built-in commands. It serves as the default connector
|
||||
/// for incoming ACP sessions before they are routed to an external agent.
|
||||
Gateway,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectorKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConnectorKind::OpenCode => write!(f, "OpenCode"),
|
||||
ConnectorKind::Acp => write!(f, "ACP"),
|
||||
ConnectorKind::Mock => write!(f, "Mock"),
|
||||
ConnectorKind::Acceptor => write!(f, "Acceptor"),
|
||||
ConnectorKind::Gateway => write!(f, "Gateway"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectorKind {
|
||||
/// Whether this connector kind can receive session transfers
|
||||
///
|
||||
/// Returns true for connector types that can have sessions
|
||||
/// transferred to them from Gateway or other sources.
|
||||
pub fn supports_session_transfer(&self) -> bool {
|
||||
match self {
|
||||
ConnectorKind::OpenCode => true,
|
||||
ConnectorKind::Acp => true,
|
||||
ConnectorKind::Gateway => true,
|
||||
ConnectorKind::Mock => false, // Testing only
|
||||
ConnectorKind::Acceptor => false, // Entry point only, doesn't process
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current state of a connector
|
||||
///
|
||||
/// Tracks the lifecycle state of a connector from initialization through
|
||||
/// active operation to shutdown. State transitions are managed by the
|
||||
/// connector's task loop and can be observed by clients.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ConnectorState {
|
||||
/// Connector is being initialized but not yet connecting
|
||||
Initializing,
|
||||
/// Connector is attempting to establish connection
|
||||
Connecting,
|
||||
/// Connector is connected and operational
|
||||
Ready,
|
||||
/// Connector encountered an error (contains error message)
|
||||
Error(String),
|
||||
/// Connector has been stopped (intentionally or after unrecoverable error)
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Structured reason for connector error state.
|
||||
///
|
||||
/// When `ConnectorState` is `Error(String)`, this provides a machine-readable
|
||||
/// classification of the error for UI display and capability computation.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ConnectorErrorKind {
|
||||
/// Service is unreachable (max retries/rapid disconnects exhausted at startup)
|
||||
Offline,
|
||||
/// Connection keeps dropping (max rapid disconnects exhausted after stable connection)
|
||||
Unstable,
|
||||
/// Initial connection failed (auth, config, network error)
|
||||
ConnectionFailed,
|
||||
}
|
||||
|
||||
/// Summary information about a connector
|
||||
///
|
||||
/// Provides a lightweight view of a connector's essential properties.
|
||||
/// Used by list operations and UI display without requiring full connector details.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ConnectorSummary {
|
||||
/// Unique connector identifier
|
||||
pub id: ConnectorId,
|
||||
/// Type of connector
|
||||
pub kind: ConnectorKind,
|
||||
/// User who owns this connector
|
||||
pub owner: UserId,
|
||||
/// Human-readable title for display
|
||||
pub title: String,
|
||||
/// Current operational state
|
||||
pub state: ConnectorState,
|
||||
/// Working directory for this connector
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub working_directory: Option<String>,
|
||||
/// Supported features for this connector (e.g., "cancellation", "session_resume")
|
||||
#[serde(default)]
|
||||
pub supported_features: Vec<String>,
|
||||
/// T034: Optional custom icon path for this connector
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_path: Option<String>,
|
||||
/// T035: Show connector type emoji as overlay on custom icon
|
||||
#[serde(default)]
|
||||
pub show_type_overlay: bool,
|
||||
/// Whether archiving can be toggled for sessions on this connector
|
||||
///
|
||||
/// When `false`, the UI should show archiving status as read-only (non-clickable).
|
||||
/// When `true`, the user can toggle archiving on/off for sessions.
|
||||
#[serde(default = "default_archiving_toggleable")]
|
||||
pub archiving_toggleable: bool,
|
||||
/// Agent type for automatic mode/model mapping (Claude, Codex, Gemini, or Custom)
|
||||
///
|
||||
/// This is only set for ACP connectors and indicates the specific agent
|
||||
/// implementation behind the connector. Used during session transfers to
|
||||
/// automatically map Gateway mode/model identifiers to agent-specific ones.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_type: Option<crate::connectors::acp::config::ConnectorAgentType>,
|
||||
|
||||
/// Tool configuration for this connector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_configuration: Option<crate::tools::ToolConfiguration>,
|
||||
|
||||
/// Plugin assignments for this connector.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub plugin_assignments: Vec<crate::plugins::PluginAssignment>,
|
||||
|
||||
/// Project assignments for this connector.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub project_assignments: Vec<crate::plugins::ProjectAssignment>,
|
||||
|
||||
/// Whether this connector is used in newly created projects by default.
|
||||
#[serde(default = "default_true")]
|
||||
pub use_in_new_projects: bool,
|
||||
|
||||
/// Source of this connector (e.g., "user", "zed").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
|
||||
/// Zed agent name this connector was created from.
|
||||
///
|
||||
/// When set, identifies this connector as Zed-managed and enables
|
||||
/// automatic binary path refresh when Zed upgrades agents.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub zed_agent_name: Option<String>,
|
||||
|
||||
/// Structured error classification when state is Error
|
||||
///
|
||||
/// Provides machine-readable error type for UI indicators and capability computation.
|
||||
/// Only set when `state` is `Error(_)`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_kind: Option<ConnectorErrorKind>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_archiving_toggleable() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl ConnectorSummary {
|
||||
/// Check if this connector supports cancellation
|
||||
pub fn supports_cancellation(&self) -> bool {
|
||||
self.supported_features.iter().any(|f| f == "cancellation")
|
||||
}
|
||||
|
||||
/// Check if this connector supports session resume
|
||||
pub fn supports_session_resume(&self) -> bool {
|
||||
self.supported_features
|
||||
.iter()
|
||||
.any(|f| f == "session_resume")
|
||||
}
|
||||
|
||||
/// Check if this connector supports listing sessions from the connector API
|
||||
///
|
||||
/// When false, session listing falls back to archived sessions only.
|
||||
/// Connectors that do not expose a session list endpoint (e.g., ACP agents
|
||||
/// that only support creating new sessions) should not advertise this feature.
|
||||
pub fn supports_session_list(&self) -> bool {
|
||||
self.supported_features
|
||||
.iter()
|
||||
.any(|f| f == "session_list")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_type_aliases() {
|
||||
let connector_id: ConnectorId = "conn-001".to_string();
|
||||
let user_id: UserId = uuid::Uuid::nil();
|
||||
assert_eq!(connector_id, "conn-001");
|
||||
assert_eq!(user_id, uuid::Uuid::nil());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_struct() {
|
||||
let user = User::new(UserProfile {
|
||||
name: Some("Test User".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(user.display_name(), "Test User");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connector_kind_enum() {
|
||||
let open_code = ConnectorKind::OpenCode;
|
||||
let acp = ConnectorKind::Acp;
|
||||
let mock = ConnectorKind::Mock;
|
||||
|
||||
assert_eq!(open_code, ConnectorKind::OpenCode);
|
||||
assert_ne!(open_code, acp);
|
||||
assert_ne!(acp, mock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connector_state_enum() {
|
||||
let _initializing = ConnectorState::Initializing;
|
||||
let _connecting = ConnectorState::Connecting;
|
||||
let ready = ConnectorState::Ready;
|
||||
let error = ConnectorState::Error("Connection failed".to_string());
|
||||
let stopped = ConnectorState::Stopped;
|
||||
|
||||
assert_eq!(ready, ConnectorState::Ready);
|
||||
assert_eq!(
|
||||
error,
|
||||
ConnectorState::Error("Connection failed".to_string())
|
||||
);
|
||||
assert_ne!(ready, stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connector_summary_struct() {
|
||||
let summary = ConnectorSummary {
|
||||
id: "conn-001".to_string(),
|
||||
kind: ConnectorKind::OpenCode,
|
||||
owner: uuid::Uuid::nil(),
|
||||
title: "My Connector".to_string(),
|
||||
state: ConnectorState::Ready,
|
||||
working_directory: None,
|
||||
supported_features: vec!["cancellation".to_string()],
|
||||
icon_path: None,
|
||||
show_type_overlay: false,
|
||||
archiving_toggleable: true,
|
||||
agent_type: None,
|
||||
tool_configuration: None,
|
||||
plugin_assignments: vec![],
|
||||
project_assignments: vec![],
|
||||
use_in_new_projects: true,
|
||||
source: None,
|
||||
zed_agent_name: None,
|
||||
error_kind: None,
|
||||
};
|
||||
|
||||
assert_eq!(summary.id, "conn-001");
|
||||
assert_eq!(summary.kind, ConnectorKind::OpenCode);
|
||||
assert_eq!(summary.owner, uuid::Uuid::nil());
|
||||
assert_eq!(summary.title, "My Connector");
|
||||
assert_eq!(summary.state, ConnectorState::Ready);
|
||||
assert!(summary.supports_cancellation());
|
||||
assert!(summary.archiving_toggleable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
let user = User::new(UserProfile {
|
||||
name: Some("Test User".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Test serialization
|
||||
let json = serde_json::to_string(&user).expect("Failed to serialize");
|
||||
assert!(json.contains("Test User"));
|
||||
|
||||
// Test deserialization
|
||||
let deserialized: User = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
assert_eq!(deserialized.id, user.id);
|
||||
assert_eq!(deserialized.display_name(), user.display_name());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connector_summary_serialization() {
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
let summary = ConnectorSummary {
|
||||
id: "conn-001".to_string(),
|
||||
kind: ConnectorKind::Acp,
|
||||
owner: uuid::Uuid::nil(),
|
||||
title: "ACP Connector".to_string(),
|
||||
state: ConnectorState::Connecting,
|
||||
working_directory: None,
|
||||
supported_features: vec!["session_resume".to_string()],
|
||||
icon_path: Some("/path/to/icon.png".to_string()),
|
||||
show_type_overlay: true,
|
||||
archiving_toggleable: true,
|
||||
agent_type: Some(ConnectorAgentType::Claude),
|
||||
tool_configuration: None,
|
||||
plugin_assignments: vec![],
|
||||
project_assignments: vec![],
|
||||
use_in_new_projects: true,
|
||||
source: None,
|
||||
zed_agent_name: None,
|
||||
error_kind: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&summary).expect("Failed to serialize");
|
||||
let deserialized: ConnectorSummary =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(deserialized.id, summary.id);
|
||||
assert_eq!(deserialized.kind, summary.kind);
|
||||
assert_eq!(deserialized.owner, summary.owner);
|
||||
assert_eq!(deserialized.title, summary.title);
|
||||
assert_eq!(deserialized.state, summary.state);
|
||||
assert_eq!(deserialized.supported_features, summary.supported_features);
|
||||
assert_eq!(deserialized.icon_path, summary.icon_path);
|
||||
assert_eq!(deserialized.show_type_overlay, summary.show_type_overlay);
|
||||
assert_eq!(
|
||||
deserialized.archiving_toggleable,
|
||||
summary.archiving_toggleable
|
||||
);
|
||||
assert_eq!(deserialized.agent_type, summary.agent_type);
|
||||
assert!(deserialized.supports_session_resume());
|
||||
assert!(!deserialized.supports_cancellation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archiving_toggleable_default() {
|
||||
// Test that archiving_toggleable defaults to true when not specified in JSON
|
||||
let json = r#"{
|
||||
"id": "conn-001",
|
||||
"kind": "OpenCode",
|
||||
"owner": "00000000-0000-0000-0000-000000000000",
|
||||
"title": "Test Connector",
|
||||
"state": "Ready",
|
||||
"supported_features": []
|
||||
}"#;
|
||||
|
||||
let deserialized: ConnectorSummary =
|
||||
serde_json::from_str(json).expect("Failed to deserialize");
|
||||
assert!(
|
||||
deserialized.archiving_toggleable,
|
||||
"archiving_toggleable should default to true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_session_transfer() {
|
||||
assert!(ConnectorKind::OpenCode.supports_session_transfer());
|
||||
assert!(ConnectorKind::Acp.supports_session_transfer());
|
||||
assert!(ConnectorKind::Gateway.supports_session_transfer());
|
||||
assert!(!ConnectorKind::Mock.supports_session_transfer());
|
||||
assert!(!ConnectorKind::Acceptor.supports_session_transfer());
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
//! Claude vendor definition
|
||||
//!
|
||||
//! This module contains all Claude-specific knowledge including CLI detection,
|
||||
//! mode/model mappings, and connector templates.
|
||||
|
||||
use super::{MappingResult, ModeMapping, ModelMapping, VendorInfo};
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
/// Claude mode identifiers
|
||||
pub mod modes {
|
||||
pub const DEFAULT: &str = "default";
|
||||
pub const PLAN: &str = "plan";
|
||||
pub const ACCEPT_EDITS: &str = "acceptEdits";
|
||||
pub const BYPASS_PERMISSIONS: &str = "bypassPermissions";
|
||||
}
|
||||
|
||||
/// Claude model identifiers
|
||||
pub mod models {
|
||||
pub const DEFAULT: &str = "default";
|
||||
pub const HAIKU: &str = "haiku";
|
||||
pub const SONNET: &str = "sonnet";
|
||||
pub const OPUS: &str = "opus";
|
||||
}
|
||||
|
||||
/// Gateway mode identifiers (for reference)
|
||||
mod gateway_modes {
|
||||
pub const PLAN: &str = "plan";
|
||||
pub const READONLY: &str = "readonly";
|
||||
pub const ASK: &str = "ask";
|
||||
pub const WRITE: &str = "write";
|
||||
pub const YOLO: &str = "yolo";
|
||||
}
|
||||
|
||||
/// Gateway model identifiers (for reference)
|
||||
mod gateway_models {
|
||||
pub const SIMPLE: &str = "simple";
|
||||
pub const DAILYDRIVER: &str = "dailydriver";
|
||||
pub const HIGH: &str = "high";
|
||||
}
|
||||
|
||||
/// Static mode mappings for Claude
|
||||
static MODE_MAPPINGS: &[ModeMapping] = &[
|
||||
ModeMapping {
|
||||
gateway: gateway_modes::ASK,
|
||||
vendor: modes::DEFAULT,
|
||||
},
|
||||
ModeMapping {
|
||||
gateway: gateway_modes::PLAN,
|
||||
vendor: modes::PLAN,
|
||||
},
|
||||
ModeMapping {
|
||||
gateway: gateway_modes::WRITE,
|
||||
vendor: modes::ACCEPT_EDITS,
|
||||
},
|
||||
ModeMapping {
|
||||
gateway: gateway_modes::YOLO,
|
||||
vendor: modes::BYPASS_PERMISSIONS,
|
||||
},
|
||||
];
|
||||
|
||||
/// Static model mappings for Claude
|
||||
static MODEL_MAPPINGS: &[ModelMapping] = &[
|
||||
ModelMapping {
|
||||
gateway: gateway_models::SIMPLE,
|
||||
vendor: models::HAIKU,
|
||||
},
|
||||
ModelMapping {
|
||||
gateway: gateway_models::DAILYDRIVER,
|
||||
vendor: models::SONNET,
|
||||
},
|
||||
ModelMapping {
|
||||
gateway: gateway_models::HIGH,
|
||||
vendor: models::OPUS,
|
||||
},
|
||||
];
|
||||
|
||||
/// Claude vendor implementation
|
||||
pub struct ClaudeVendor;
|
||||
|
||||
impl VendorInfo for ClaudeVendor {
|
||||
fn id(&self) -> &'static str {
|
||||
"claude"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Claude"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["anthropic"]
|
||||
}
|
||||
|
||||
fn agent_type(&self) -> ConnectorAgentType {
|
||||
ConnectorAgentType::Claude
|
||||
}
|
||||
|
||||
fn cli_command(&self) -> &'static str {
|
||||
"claude"
|
||||
}
|
||||
|
||||
fn cli_args(&self) -> &'static [&'static str] {
|
||||
&["--acp"]
|
||||
}
|
||||
|
||||
fn mode_mappings(&self) -> &'static [ModeMapping] {
|
||||
MODE_MAPPINGS
|
||||
}
|
||||
|
||||
fn model_mappings(&self) -> &'static [ModelMapping] {
|
||||
MODEL_MAPPINGS
|
||||
}
|
||||
|
||||
fn map_mode(&self, gateway_mode: &str) -> MappingResult {
|
||||
match gateway_mode {
|
||||
gateway_modes::ASK => MappingResult::exact(modes::DEFAULT),
|
||||
gateway_modes::PLAN => MappingResult::exact(modes::PLAN),
|
||||
gateway_modes::READONLY => MappingResult::approximate(
|
||||
modes::PLAN,
|
||||
"Gateway 'readonly' mode mapped to Claude 'plan' mode",
|
||||
),
|
||||
gateway_modes::WRITE => MappingResult::exact(modes::ACCEPT_EDITS),
|
||||
gateway_modes::YOLO => MappingResult::exact(modes::BYPASS_PERMISSIONS),
|
||||
_ => MappingResult::approximate(
|
||||
modes::DEFAULT,
|
||||
format!("Unknown gateway mode '{}' mapped to Claude 'default' mode", gateway_mode),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_model(&self, gateway_model: &str) -> MappingResult {
|
||||
match gateway_model {
|
||||
gateway_models::SIMPLE => MappingResult::exact(models::HAIKU),
|
||||
gateway_models::DAILYDRIVER => MappingResult::exact(models::SONNET),
|
||||
gateway_models::HIGH => MappingResult::exact(models::OPUS),
|
||||
_ => MappingResult::approximate(
|
||||
models::SONNET,
|
||||
format!(
|
||||
"Unknown gateway model '{}' mapped to Claude 'sonnet' model",
|
||||
gateway_model
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse_map_mode(&self, vendor_mode: &str) -> MappingResult {
|
||||
match vendor_mode {
|
||||
modes::DEFAULT => MappingResult::exact(gateway_modes::ASK),
|
||||
modes::PLAN => MappingResult::exact(gateway_modes::PLAN),
|
||||
modes::ACCEPT_EDITS => MappingResult::exact(gateway_modes::WRITE),
|
||||
modes::BYPASS_PERMISSIONS => MappingResult::exact(gateway_modes::YOLO),
|
||||
_ => MappingResult::approximate(
|
||||
gateway_modes::ASK,
|
||||
format!("Unknown Claude mode '{}' mapped to Gateway 'ask' mode", vendor_mode),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn reverse_map_model(&self, vendor_model: &str) -> MappingResult {
|
||||
match vendor_model {
|
||||
models::HAIKU => MappingResult::exact(gateway_models::SIMPLE),
|
||||
models::SONNET => MappingResult::exact(gateway_models::DAILYDRIVER),
|
||||
models::OPUS => MappingResult::exact(gateway_models::HIGH),
|
||||
models::DEFAULT => MappingResult::exact(gateway_models::DAILYDRIVER),
|
||||
_ => MappingResult::approximate(
|
||||
gateway_models::DAILYDRIVER,
|
||||
format!(
|
||||
"Unknown Claude model '{}' mapped to Gateway 'dailydriver' model",
|
||||
vendor_model
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn acp_template(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": "claude",
|
||||
"args": ["--acp"]
|
||||
},
|
||||
"protocol_version": 1,
|
||||
"cwd": ".",
|
||||
"retry": {
|
||||
"max_retries": 5,
|
||||
"retry_delays_ms": [1000, 3000, 5000, 5000, 5000]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn default_features(&self) -> &'static [&'static str] {
|
||||
// Claude Code has limited ACP support:
|
||||
// - NOT session_resume: generates ephemeral ACP session IDs
|
||||
// - NOT cancellation: session/cancel not implemented
|
||||
&[]
|
||||
}
|
||||
|
||||
fn icon_path(&self) -> &'static str {
|
||||
"claude"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_claude_vendor_id() {
|
||||
let vendor = ClaudeVendor;
|
||||
assert_eq!(vendor.id(), "claude");
|
||||
assert_eq!(vendor.display_name(), "Claude");
|
||||
assert_eq!(vendor.aliases(), &["anthropic"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_mode_mappings() {
|
||||
let vendor = ClaudeVendor;
|
||||
|
||||
// Forward mappings
|
||||
assert_eq!(vendor.map_mode("ask").mapped_id, "default");
|
||||
assert_eq!(vendor.map_mode("plan").mapped_id, "plan");
|
||||
assert_eq!(vendor.map_mode("write").mapped_id, "acceptEdits");
|
||||
assert_eq!(vendor.map_mode("yolo").mapped_id, "bypassPermissions");
|
||||
|
||||
// Reverse mappings
|
||||
assert_eq!(vendor.reverse_map_mode("default").mapped_id, "ask");
|
||||
assert_eq!(vendor.reverse_map_mode("plan").mapped_id, "plan");
|
||||
assert_eq!(vendor.reverse_map_mode("acceptEdits").mapped_id, "write");
|
||||
assert_eq!(vendor.reverse_map_mode("bypassPermissions").mapped_id, "yolo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_model_mappings() {
|
||||
let vendor = ClaudeVendor;
|
||||
|
||||
// Forward mappings
|
||||
assert_eq!(vendor.map_model("simple").mapped_id, "haiku");
|
||||
assert_eq!(vendor.map_model("dailydriver").mapped_id, "sonnet");
|
||||
assert_eq!(vendor.map_model("high").mapped_id, "opus");
|
||||
|
||||
// Reverse mappings
|
||||
assert_eq!(vendor.reverse_map_model("haiku").mapped_id, "simple");
|
||||
assert_eq!(vendor.reverse_map_model("sonnet").mapped_id, "dailydriver");
|
||||
assert_eq!(vendor.reverse_map_model("opus").mapped_id, "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_unknown_mode() {
|
||||
let vendor = ClaudeVendor;
|
||||
let result = vendor.map_mode("unknown");
|
||||
assert_eq!(result.mapped_id, "default");
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_cli() {
|
||||
let vendor = ClaudeVendor;
|
||||
assert_eq!(vendor.cli_command(), "claude");
|
||||
assert_eq!(vendor.cli_args(), &["--acp"]);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
//! Codex vendor definition (placeholder)
|
||||
//!
|
||||
//! This module contains Codex-specific knowledge. Currently a placeholder
|
||||
//! with pass-through behavior until Codex mappings are implemented.
|
||||
|
||||
use super::{ModeMapping, ModelMapping, VendorInfo};
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
/// Codex vendor implementation (placeholder)
|
||||
pub struct CodexVendor;
|
||||
|
||||
impl VendorInfo for CodexVendor {
|
||||
fn id(&self) -> &'static str {
|
||||
"codex"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Codex"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["openai"]
|
||||
}
|
||||
|
||||
fn agent_type(&self) -> ConnectorAgentType {
|
||||
ConnectorAgentType::Codex
|
||||
}
|
||||
|
||||
fn cli_command(&self) -> &'static str {
|
||||
"codex"
|
||||
}
|
||||
|
||||
fn mode_mappings(&self) -> &'static [ModeMapping] {
|
||||
// Not yet implemented - pass through
|
||||
&[]
|
||||
}
|
||||
|
||||
fn model_mappings(&self) -> &'static [ModelMapping] {
|
||||
// Not yet implemented - pass through
|
||||
&[]
|
||||
}
|
||||
|
||||
fn default_features(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn icon_path(&self) -> &'static str {
|
||||
"codex"
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
//! Custom/fallback vendor definition
|
||||
//!
|
||||
//! This vendor is used when the agent type is unknown or custom.
|
||||
//! All mappings pass through unchanged.
|
||||
|
||||
use super::{MappingResult, ModeMapping, ModelMapping, VendorInfo};
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
/// Custom vendor implementation (pass-through)
|
||||
pub struct CustomVendor;
|
||||
|
||||
impl VendorInfo for CustomVendor {
|
||||
fn id(&self) -> &'static str {
|
||||
"custom"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Custom"
|
||||
}
|
||||
|
||||
fn agent_type(&self) -> ConnectorAgentType {
|
||||
ConnectorAgentType::Custom
|
||||
}
|
||||
|
||||
fn cli_command(&self) -> &'static str {
|
||||
""
|
||||
}
|
||||
|
||||
fn mode_mappings(&self) -> &'static [ModeMapping] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn model_mappings(&self) -> &'static [ModelMapping] {
|
||||
&[]
|
||||
}
|
||||
|
||||
// Override to pass through without warnings
|
||||
fn map_mode(&self, gateway_mode: &str) -> MappingResult {
|
||||
MappingResult::exact(gateway_mode)
|
||||
}
|
||||
|
||||
fn map_model(&self, gateway_model: &str) -> MappingResult {
|
||||
MappingResult::exact(gateway_model)
|
||||
}
|
||||
|
||||
fn reverse_map_mode(&self, vendor_mode: &str) -> MappingResult {
|
||||
MappingResult::exact(vendor_mode)
|
||||
}
|
||||
|
||||
fn reverse_map_model(&self, vendor_model: &str) -> MappingResult {
|
||||
MappingResult::exact(vendor_model)
|
||||
}
|
||||
|
||||
fn icon_path(&self) -> &'static str {
|
||||
"acp"
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
//! Gemini vendor definition (placeholder)
|
||||
//!
|
||||
//! This module contains Gemini-specific knowledge. Currently a placeholder
|
||||
//! with pass-through behavior until Gemini mappings are implemented.
|
||||
|
||||
use super::{ModeMapping, ModelMapping, VendorInfo};
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
/// Gemini vendor implementation (placeholder)
|
||||
pub struct GeminiVendor;
|
||||
|
||||
impl VendorInfo for GeminiVendor {
|
||||
fn id(&self) -> &'static str {
|
||||
"gemini"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &'static str {
|
||||
"Gemini"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["google"]
|
||||
}
|
||||
|
||||
fn agent_type(&self) -> ConnectorAgentType {
|
||||
ConnectorAgentType::Gemini
|
||||
}
|
||||
|
||||
fn cli_command(&self) -> &'static str {
|
||||
"gemini"
|
||||
}
|
||||
|
||||
fn mode_mappings(&self) -> &'static [ModeMapping] {
|
||||
// Not yet implemented - pass through
|
||||
&[]
|
||||
}
|
||||
|
||||
fn model_mappings(&self) -> &'static [ModelMapping] {
|
||||
// Not yet implemented - pass through
|
||||
&[]
|
||||
}
|
||||
|
||||
fn default_features(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn icon_path(&self) -> &'static str {
|
||||
"gemini"
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
//! Vendor registry for agent-specific knowledge
|
||||
//!
|
||||
//! This module consolidates vendor-specific configuration and behavior into a single location.
|
||||
//! When adding support for a new vendor (Claude, Codex, Gemini, etc.), create a new module
|
||||
//! in this directory implementing the `VendorInfo` trait.
|
||||
//!
|
||||
//! # Adding a New Vendor
|
||||
//!
|
||||
//! 1. Create a new file in `vendors/` (e.g., `gemini.rs`)
|
||||
//! 2. Implement the `VendorInfo` trait for your vendor
|
||||
//! 3. Register your vendor in the `VENDOR_REGISTRY` in this file
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use dirigent_core::vendors::{VENDOR_REGISTRY, VendorInfo};
|
||||
//!
|
||||
//! // Look up vendor by ID
|
||||
//! if let Some(vendor) = VENDOR_REGISTRY.get("claude") {
|
||||
//! println!("CLI command: {}", vendor.cli_command());
|
||||
//! println!("Display name: {}", vendor.display_name());
|
||||
//! }
|
||||
//!
|
||||
//! // Detect which vendors are available on the system
|
||||
//! let available = VENDOR_REGISTRY.detect_available();
|
||||
//! for vendor in available {
|
||||
//! println!("{} is installed", vendor.display_name());
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod claude;
|
||||
mod codex;
|
||||
mod custom;
|
||||
mod gemini;
|
||||
mod registry;
|
||||
|
||||
pub use claude::ClaudeVendor;
|
||||
pub use codex::CodexVendor;
|
||||
pub use custom::CustomVendor;
|
||||
pub use gemini::GeminiVendor;
|
||||
pub use registry::{VendorRegistry, VENDOR_REGISTRY};
|
||||
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
/// Mode mapping entry
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModeMapping {
|
||||
/// Gateway mode identifier
|
||||
pub gateway: &'static str,
|
||||
/// Vendor-specific mode identifier
|
||||
pub vendor: &'static str,
|
||||
}
|
||||
|
||||
/// Model mapping entry
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelMapping {
|
||||
/// Gateway model identifier
|
||||
pub gateway: &'static str,
|
||||
/// Vendor-specific model identifier
|
||||
pub vendor: &'static str,
|
||||
}
|
||||
|
||||
/// Result of a mode/model mapping operation
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MappingResult {
|
||||
/// The mapped identifier
|
||||
pub mapped_id: String,
|
||||
/// Warning message if mapping was approximate
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
impl MappingResult {
|
||||
/// Create an exact mapping result
|
||||
pub fn exact(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
mapped_id: id.into(),
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an approximate mapping with a warning
|
||||
pub fn approximate(id: impl Into<String>, warning: impl Into<String>) -> Self {
|
||||
Self {
|
||||
mapped_id: id.into(),
|
||||
warning: Some(warning.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait defining vendor-specific knowledge
|
||||
///
|
||||
/// Implement this trait for each vendor (Claude, Codex, Gemini, etc.) to consolidate
|
||||
/// all vendor-specific configuration in one place.
|
||||
pub trait VendorInfo: Send + Sync {
|
||||
/// Unique vendor identifier (lowercase, e.g., "claude", "codex")
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
/// Human-readable display name (e.g., "Claude", "Codex")
|
||||
fn display_name(&self) -> &'static str;
|
||||
|
||||
/// Alternative names that map to this vendor (e.g., ["anthropic"] for Claude)
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// The corresponding ConnectorAgentType enum value
|
||||
fn agent_type(&self) -> ConnectorAgentType;
|
||||
|
||||
// =========================================================================
|
||||
// CLI Detection
|
||||
// =========================================================================
|
||||
|
||||
/// CLI command name for this vendor (e.g., "claude" for Claude)
|
||||
fn cli_command(&self) -> &'static str;
|
||||
|
||||
/// Default CLI arguments (e.g., ["--acp"] for Claude)
|
||||
fn cli_args(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// CLI command name on Windows (defaults to cli_command)
|
||||
fn cli_command_windows(&self) -> &'static str {
|
||||
self.cli_command()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mode/Model Mappings
|
||||
// =========================================================================
|
||||
|
||||
/// Mode mappings from Gateway identifiers to vendor-specific identifiers
|
||||
fn mode_mappings(&self) -> &'static [ModeMapping] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Model mappings from Gateway identifiers to vendor-specific identifiers
|
||||
fn model_mappings(&self) -> &'static [ModelMapping] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Map a Gateway mode to vendor-specific mode
|
||||
fn map_mode(&self, gateway_mode: &str) -> MappingResult {
|
||||
for mapping in self.mode_mappings() {
|
||||
if mapping.gateway == gateway_mode {
|
||||
return MappingResult::exact(mapping.vendor);
|
||||
}
|
||||
}
|
||||
// Default: pass through with warning
|
||||
MappingResult::approximate(
|
||||
gateway_mode,
|
||||
format!(
|
||||
"Unknown mode '{}' for {}",
|
||||
gateway_mode,
|
||||
self.display_name()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Map a Gateway model to vendor-specific model
|
||||
fn map_model(&self, gateway_model: &str) -> MappingResult {
|
||||
for mapping in self.model_mappings() {
|
||||
if mapping.gateway == gateway_model {
|
||||
return MappingResult::exact(mapping.vendor);
|
||||
}
|
||||
}
|
||||
// Default: pass through with warning
|
||||
MappingResult::approximate(
|
||||
gateway_model,
|
||||
format!(
|
||||
"Unknown model '{}' for {}",
|
||||
gateway_model,
|
||||
self.display_name()
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reverse map a vendor-specific mode to Gateway mode
|
||||
fn reverse_map_mode(&self, vendor_mode: &str) -> MappingResult {
|
||||
for mapping in self.mode_mappings() {
|
||||
if mapping.vendor == vendor_mode {
|
||||
return MappingResult::exact(mapping.gateway);
|
||||
}
|
||||
}
|
||||
// Default: pass through with warning
|
||||
MappingResult::approximate(
|
||||
vendor_mode,
|
||||
format!("Unknown {} mode '{}'", self.display_name(), vendor_mode),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reverse map a vendor-specific model to Gateway model
|
||||
fn reverse_map_model(&self, vendor_model: &str) -> MappingResult {
|
||||
for mapping in self.model_mappings() {
|
||||
if mapping.vendor == vendor_model {
|
||||
return MappingResult::exact(mapping.gateway);
|
||||
}
|
||||
}
|
||||
// Default: pass through with warning
|
||||
MappingResult::approximate(
|
||||
vendor_model,
|
||||
format!("Unknown {} model '{}'", self.display_name(), vendor_model),
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Templates & Defaults
|
||||
// =========================================================================
|
||||
|
||||
/// Default ACP connector template for this vendor
|
||||
fn acp_template(&self) -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
/// Default supported features for this vendor
|
||||
fn default_features(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Default icon path for this vendor
|
||||
fn icon_path(&self) -> &'static str {
|
||||
"acp"
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
//! Vendor registry for looking up vendor implementations
|
||||
//!
|
||||
//! The registry provides a centralized way to look up vendors by ID, alias,
|
||||
//! or ConnectorAgentType.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{ClaudeVendor, CodexVendor, CustomVendor, GeminiVendor, VendorInfo};
|
||||
use crate::connectors::acp::config::ConnectorAgentType;
|
||||
|
||||
/// Global vendor registry instance
|
||||
pub static VENDOR_REGISTRY: Lazy<VendorRegistry> = Lazy::new(VendorRegistry::new);
|
||||
|
||||
/// Registry of all known vendors
|
||||
pub struct VendorRegistry {
|
||||
/// Vendors indexed by their primary ID
|
||||
vendors: HashMap<&'static str, Box<dyn VendorInfo>>,
|
||||
/// Alias to vendor ID mapping
|
||||
aliases: HashMap<&'static str, &'static str>,
|
||||
}
|
||||
|
||||
impl VendorRegistry {
|
||||
/// Create a new vendor registry with all known vendors
|
||||
pub fn new() -> Self {
|
||||
let mut registry = Self {
|
||||
vendors: HashMap::new(),
|
||||
aliases: HashMap::new(),
|
||||
};
|
||||
|
||||
// Register all vendors
|
||||
registry.register(Box::new(ClaudeVendor));
|
||||
registry.register(Box::new(CodexVendor));
|
||||
registry.register(Box::new(GeminiVendor));
|
||||
registry.register(Box::new(CustomVendor));
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
/// Register a vendor in the registry
|
||||
fn register(&mut self, vendor: Box<dyn VendorInfo>) {
|
||||
let id = vendor.id();
|
||||
|
||||
// Register aliases
|
||||
for alias in vendor.aliases() {
|
||||
self.aliases.insert(alias, id);
|
||||
}
|
||||
|
||||
// Register the vendor
|
||||
self.vendors.insert(id, vendor);
|
||||
}
|
||||
|
||||
/// Look up a vendor by ID or alias
|
||||
pub fn get(&self, id_or_alias: &str) -> Option<&dyn VendorInfo> {
|
||||
let id = self
|
||||
.aliases
|
||||
.get(id_or_alias)
|
||||
.copied()
|
||||
.unwrap_or(id_or_alias);
|
||||
self.vendors.get(id).map(|v| v.as_ref())
|
||||
}
|
||||
|
||||
/// Look up a vendor by ConnectorAgentType
|
||||
pub fn get_by_agent_type(&self, agent_type: ConnectorAgentType) -> Option<&dyn VendorInfo> {
|
||||
match agent_type {
|
||||
ConnectorAgentType::Claude => self.get("claude"),
|
||||
ConnectorAgentType::Codex => self.get("codex"),
|
||||
ConnectorAgentType::Gemini => self.get("gemini"),
|
||||
ConnectorAgentType::Custom => self.get("custom"),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all registered vendors
|
||||
pub fn list_all(&self) -> Vec<&dyn VendorInfo> {
|
||||
self.vendors.values().map(|v| v.as_ref()).collect()
|
||||
}
|
||||
|
||||
/// List vendor IDs
|
||||
pub fn list_ids(&self) -> Vec<&'static str> {
|
||||
self.vendors.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Check if a vendor CLI is available on the system
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn is_cli_available(&self, vendor_id: &str) -> bool {
|
||||
use std::process::Command;
|
||||
|
||||
let Some(vendor) = self.get(vendor_id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let command = vendor.cli_command();
|
||||
if command.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use 'which' on Unix, 'where' on Windows
|
||||
#[cfg(target_os = "windows")]
|
||||
let check_cmd = "where";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let check_cmd = "which";
|
||||
|
||||
Command::new(check_cmd)
|
||||
.arg(command)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Detect which vendor CLIs are available on the system
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn detect_available(&self) -> Vec<&dyn VendorInfo> {
|
||||
self.vendors
|
||||
.values()
|
||||
.filter(|v| {
|
||||
let cmd = v.cli_command();
|
||||
!cmd.is_empty() && self.is_cli_available(v.id())
|
||||
})
|
||||
.map(|v| v.as_ref())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VendorRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_by_id() {
|
||||
let registry = VendorRegistry::new();
|
||||
|
||||
let claude = registry.get("claude").unwrap();
|
||||
assert_eq!(claude.id(), "claude");
|
||||
assert_eq!(claude.display_name(), "Claude");
|
||||
|
||||
let codex = registry.get("codex").unwrap();
|
||||
assert_eq!(codex.id(), "codex");
|
||||
|
||||
let gemini = registry.get("gemini").unwrap();
|
||||
assert_eq!(gemini.id(), "gemini");
|
||||
|
||||
let custom = registry.get("custom").unwrap();
|
||||
assert_eq!(custom.id(), "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_by_alias() {
|
||||
let registry = VendorRegistry::new();
|
||||
|
||||
// "anthropic" should resolve to "claude"
|
||||
let claude = registry.get("anthropic").unwrap();
|
||||
assert_eq!(claude.id(), "claude");
|
||||
|
||||
// "openai" should resolve to "codex"
|
||||
let codex = registry.get("openai").unwrap();
|
||||
assert_eq!(codex.id(), "codex");
|
||||
|
||||
// "google" should resolve to "gemini"
|
||||
let gemini = registry.get("google").unwrap();
|
||||
assert_eq!(gemini.id(), "gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_by_agent_type() {
|
||||
let registry = VendorRegistry::new();
|
||||
|
||||
let claude = registry
|
||||
.get_by_agent_type(ConnectorAgentType::Claude)
|
||||
.unwrap();
|
||||
assert_eq!(claude.id(), "claude");
|
||||
|
||||
let codex = registry
|
||||
.get_by_agent_type(ConnectorAgentType::Codex)
|
||||
.unwrap();
|
||||
assert_eq!(codex.id(), "codex");
|
||||
|
||||
let gemini = registry
|
||||
.get_by_agent_type(ConnectorAgentType::Gemini)
|
||||
.unwrap();
|
||||
assert_eq!(gemini.id(), "gemini");
|
||||
|
||||
let custom = registry
|
||||
.get_by_agent_type(ConnectorAgentType::Custom)
|
||||
.unwrap();
|
||||
assert_eq!(custom.id(), "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all() {
|
||||
let registry = VendorRegistry::new();
|
||||
let vendors = registry.list_all();
|
||||
|
||||
assert_eq!(vendors.len(), 4);
|
||||
|
||||
let ids: Vec<_> = vendors.iter().map(|v| v.id()).collect();
|
||||
assert!(ids.contains(&"claude"));
|
||||
assert!(ids.contains(&"codex"));
|
||||
assert!(ids.contains(&"gemini"));
|
||||
assert!(ids.contains(&"custom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_vendor() {
|
||||
let registry = VendorRegistry::new();
|
||||
assert!(registry.get("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_registry() {
|
||||
// Test the static VENDOR_REGISTRY
|
||||
let claude = VENDOR_REGISTRY.get("claude").unwrap();
|
||||
assert_eq!(claude.id(), "claude");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,972 @@
|
||||
//! TEST-003 through TEST-007: Advanced ACP Tests
|
||||
//!
|
||||
//! This file contains advanced integration tests covering:
|
||||
//! - TEST-003: Session lifecycle tests (create, prompt, cancel, load)
|
||||
//! - TEST-004: Error condition tests (network failures, invalid JSON, timeouts)
|
||||
//! - TEST-005: Reconnection tests (during init, active session, max retries)
|
||||
//! - TEST-006: Edge case tests (empty messages, long messages, rapid-fire, leaks)
|
||||
//! - TEST-007: Performance tests (latency, throughput, memory, concurrent sessions)
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
use dirigent_core::connectors::acp::{AcpConfig, AcpConnector};
|
||||
use dirigent_core::connectors::{Connector, ConnectorCommand};
|
||||
use dirigent_core::types::ConnectorState;
|
||||
use dirigent_protocol::Event;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::timeout;
|
||||
|
||||
// ============================================================================
|
||||
// Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn test_fixture_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join("acp_test.yaml")
|
||||
}
|
||||
|
||||
fn mocker_binary() -> String {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
|
||||
let debug_path = project_root
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join("dirigate.exe");
|
||||
|
||||
if debug_path.exists() {
|
||||
debug_path.to_string_lossy().to_string()
|
||||
} else {
|
||||
"cargo".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn mocker_args() -> Vec<String> {
|
||||
let bin = mocker_binary();
|
||||
let fixture = test_fixture_path();
|
||||
|
||||
if bin == "cargo" {
|
||||
vec![
|
||||
"run".to_string(),
|
||||
"--package".to_string(),
|
||||
"dirigate".to_string(),
|
||||
"--".to_string(),
|
||||
"serve".to_string(),
|
||||
"--stdio".to_string(),
|
||||
"--fixtures".to_string(),
|
||||
fixture.to_string_lossy().to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--stdio".to_string(),
|
||||
"--fixtures".to_string(),
|
||||
fixture.to_string_lossy().to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_connector(id: &str) -> AcpConnector {
|
||||
let config = AcpConfig::stdio(mocker_binary(), mocker_args())
|
||||
.with_retry_max_attempts(3)
|
||||
.with_retry_initial_delay(Duration::from_millis(500));
|
||||
|
||||
AcpConnector::new(
|
||||
id.to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Test ACP Connector".to_string(),
|
||||
config,
|
||||
dirigent_core::sharing::bus::SharingBus::new(),
|
||||
)
|
||||
.expect("Failed to create connector")
|
||||
}
|
||||
|
||||
async fn wait_for_event<F>(
|
||||
events_rx: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
predicate: F,
|
||||
timeout_duration: Duration,
|
||||
) -> anyhow::Result<Event>
|
||||
where
|
||||
F: Fn(&Event) -> bool,
|
||||
{
|
||||
let result = timeout(timeout_duration, async {
|
||||
loop {
|
||||
match events_rx.recv().await {
|
||||
Ok(event) => {
|
||||
if predicate(&event) {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("Recv error: {}", e)),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => Ok(event),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(anyhow::anyhow!("Timeout")),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST-003: Session Lifecycle Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t003_01_session_create_flow() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("lifecycle-create");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create session
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Verify session created
|
||||
let session = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
assert_eq!(session.title, "Lifecycle Test");
|
||||
tracing::info!("✓ Session created successfully");
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t003_02_session_prompt_flow() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("lifecycle-prompt");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create session
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send prompt
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Test prompt".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Verify message flow: Started -> Content -> Completed
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("✓ Message started");
|
||||
|
||||
// Wait for content or completion
|
||||
let mut got_response = false;
|
||||
for _ in 0..10 {
|
||||
if let Ok(event) = timeout(Duration::from_secs(1), events.recv()).await {
|
||||
match event? {
|
||||
Event::SessionUpdate { .. } => {
|
||||
got_response = true;
|
||||
break;
|
||||
}
|
||||
Event::MessageCompleted { .. } => {
|
||||
got_response = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(got_response, "Should receive response");
|
||||
tracing::info!("✓ Prompt flow completed");
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t003_03_session_cancel_flow() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("lifecycle-cancel");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Setup
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Start message
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Long message".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Cancel
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CancelGeneration { session_id })
|
||||
.await?;
|
||||
|
||||
tracing::info!("✓ Cancel sent successfully");
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t003_04_session_load_flow() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("lifecycle-load");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Load existing session from fixture
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::LoadSession {
|
||||
session_id: "test-session-1".to_string(),
|
||||
cwd: ".".to_string(),
|
||||
mcp_servers: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
assert_eq!(session.id, "test-session-1");
|
||||
tracing::info!("✓ Session loaded from fixture");
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST-004: Error Condition Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t004_01_invalid_binary_path() {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let config = AcpConfig::stdio("nonexistent-binary".to_string(), vec![]);
|
||||
let result = AcpConnector::new(
|
||||
"error-test".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Error Test".to_string(),
|
||||
config,
|
||||
dirigent_core::sharing::bus::SharingBus::new(),
|
||||
);
|
||||
|
||||
// Should fail validation or fail to start
|
||||
// Either is acceptable - the key is it doesn't panic
|
||||
if let Ok(connector) = result {
|
||||
let mut events = connector.subscribe();
|
||||
let _handle = connector.start_task().await;
|
||||
|
||||
// Should either fail to connect or error out
|
||||
let result = timeout(Duration::from_secs(5), events.recv()).await;
|
||||
match result {
|
||||
Ok(Ok(Event::Error { .. })) => {
|
||||
tracing::info!("✓ Error handled gracefully");
|
||||
}
|
||||
Ok(Ok(Event::Connected)) => {
|
||||
panic!("Should not connect to nonexistent binary");
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("✓ Connection failed as expected");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("✓ Config validation caught invalid binary");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t004_02_timeout_handling() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
// Create connector with very short timeout
|
||||
let config = AcpConfig::stdio(mocker_binary(), mocker_args())
|
||||
.with_request_timeout(Duration::from_millis(100));
|
||||
|
||||
let connector = AcpConnector::new(
|
||||
"timeout-test".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Timeout Test".to_string(),
|
||||
config,
|
||||
dirigent_core::sharing::bus::SharingBus::new(),
|
||||
)?;
|
||||
let mut events = connector.subscribe();
|
||||
let _handle = connector.start_task().await;
|
||||
|
||||
// Try to connect - might timeout during handshake
|
||||
match timeout(Duration::from_secs(10), events.recv()).await {
|
||||
Ok(Ok(Event::Connected)) => {
|
||||
tracing::info!("✓ Connected despite short timeout");
|
||||
}
|
||||
Ok(Ok(Event::Error { message })) => {
|
||||
tracing::info!("✓ Timeout error handled: {}", message);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::info!("✓ Timeout occurred as expected");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t004_03_connection_state_on_error() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let config = AcpConfig::stdio("nonexistent".to_string(), vec![]);
|
||||
|
||||
if let Ok(connector) = AcpConnector::new(
|
||||
"state-test".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"State Test".to_string(),
|
||||
config,
|
||||
dirigent_core::sharing::bus::SharingBus::new(),
|
||||
) {
|
||||
let state = connector.state_arc();
|
||||
let _handle = connector.start_task().await;
|
||||
|
||||
// Wait a bit for state to update
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
let current_state = state.read().await;
|
||||
match *current_state {
|
||||
ConnectorState::Error { .. } | ConnectorState::Stopped => {
|
||||
tracing::info!("✓ State correctly reflects error: {:?}", *current_state);
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("State is: {:?}", *current_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST-005: Reconnection Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t005_01_reconnect_after_disconnect() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("reconnect-test");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
// Wait for initial connection
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("✓ Initial connection established");
|
||||
|
||||
// Stop and restart
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
|
||||
// Create new connector with same config
|
||||
let connector2 = create_test_connector("reconnect-test-2");
|
||||
let mut events2 = connector2.subscribe();
|
||||
let handle2 = connector2.start_task().await;
|
||||
|
||||
// Should reconnect
|
||||
wait_for_event(
|
||||
&mut events2,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("✓ Reconnected successfully");
|
||||
|
||||
connector2.stop();
|
||||
timeout(Duration::from_secs(5), handle2).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t005_02_max_retries_respected() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let config = AcpConfig::stdio("nonexistent".to_string(), vec![])
|
||||
.with_retry_max_attempts(2)
|
||||
.with_retry_initial_delay(Duration::from_millis(100));
|
||||
|
||||
if let Ok(connector) = AcpConnector::new(
|
||||
"retry-test".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Retry Test".to_string(),
|
||||
config,
|
||||
dirigent_core::sharing::bus::SharingBus::new(),
|
||||
) {
|
||||
let _events = connector.subscribe();
|
||||
let _handle = connector.start_task().await;
|
||||
|
||||
// Should eventually give up after max retries
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Check state is error or stopped
|
||||
let state = connector.state_arc();
|
||||
let current = state.read().await;
|
||||
match *current {
|
||||
ConnectorState::Error { .. } | ConnectorState::Stopped => {
|
||||
tracing::info!("✓ Max retries respected, connector stopped");
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("State: {:?}", *current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST-006: Edge Case Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t006_01_empty_message() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("empty-msg-test");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send empty message
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id,
|
||||
text: "".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Should handle gracefully (either respond or error)
|
||||
let result = timeout(Duration::from_secs(3), events.recv()).await;
|
||||
tracing::info!("✓ Empty message handled: {:?}", result.is_ok());
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t006_02_very_long_message() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("long-msg-test");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send very long message (10KB)
|
||||
let long_content = "x".repeat(10_000);
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id,
|
||||
text: long_content,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Should handle without issue
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("✓ Long message handled");
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t006_03_rapid_fire_requests() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("rapid-test");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send 10 messages rapidly
|
||||
for i in 0..10 {
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: format!("Message {}", i),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
tracing::info!("✓ Rapid-fire messages sent without blocking");
|
||||
|
||||
// Just verify we don't crash
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t006_04_resource_cleanup() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
// Create and destroy multiple connectors
|
||||
for i in 0..5 {
|
||||
let connector = create_test_connector(&format!("cleanup-test-{}", i));
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(2), handle).await;
|
||||
|
||||
tracing::info!("✓ Connector {} cleaned up", i);
|
||||
}
|
||||
|
||||
tracing::info!("✓ All resources cleaned up successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST-007: Performance Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t007_01_connection_latency() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("latency-test");
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
let start = Instant::now();
|
||||
let _handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let latency = start.elapsed();
|
||||
tracing::info!("✓ Connection latency: {:?}", latency);
|
||||
|
||||
// Should connect within 5 seconds
|
||||
assert!(latency < Duration::from_secs(5), "Connection took too long");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t007_02_request_roundtrip() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("roundtrip-test");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Measure session creation time
|
||||
let start = Instant::now();
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
let roundtrip = start.elapsed();
|
||||
|
||||
tracing::info!("✓ Session creation roundtrip: {:?}", roundtrip);
|
||||
assert!(roundtrip < Duration::from_secs(2), "Roundtrip too slow");
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t007_03_event_delivery_latency() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("event-latency-test");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Measure event delivery
|
||||
let start = Instant::now();
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id,
|
||||
text: "Test".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
let delivery_time = start.elapsed();
|
||||
|
||||
tracing::info!("✓ Event delivery latency: {:?}", delivery_time);
|
||||
assert!(
|
||||
delivery_time < Duration::from_secs(1),
|
||||
"Event delivery too slow"
|
||||
);
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t007_04_concurrent_session_capacity() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
let connector = create_test_connector("capacity-test");
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create 10 sessions
|
||||
let start = Instant::now();
|
||||
let mut session_ids = Vec::new();
|
||||
|
||||
for _i in 0..10 {
|
||||
connector
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Event::SessionCreated { session, .. } = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
session_ids.push(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
let total_time = start.elapsed();
|
||||
tracing::info!(
|
||||
"✓ Created {} sessions in {:?}",
|
||||
session_ids.len(),
|
||||
total_time
|
||||
);
|
||||
assert_eq!(session_ids.len(), 10);
|
||||
|
||||
connector.stop();
|
||||
timeout(Duration::from_secs(5), handle).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires mocker binary"]
|
||||
async fn test_t007_05_memory_stability() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt().with_test_writer().try_init().ok();
|
||||
|
||||
// Run 20 create/destroy cycles to check for memory leaks
|
||||
for i in 0..20 {
|
||||
let connector = create_test_connector(&format!("mem-test-{}", i));
|
||||
let mut events = connector.subscribe();
|
||||
let handle = connector.start_task().await;
|
||||
|
||||
if let Ok(_) = timeout(
|
||||
Duration::from_secs(5),
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::info!("Cycle {} connected", i);
|
||||
}
|
||||
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(2), handle).await;
|
||||
}
|
||||
|
||||
tracing::info!("✓ 20 cycles completed without crash (manual memory check needed)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Tests for ACP connector crash resilience.
|
||||
//!
|
||||
//! These tests verify behavior when child processes crash, die mid-write,
|
||||
//! or disconnect unexpectedly during active sessions.
|
||||
//!
|
||||
//! Bug reference: docs/workpad/bugs/bug_001_analysis.md
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod crash_resilience {
|
||||
use async_trait::async_trait;
|
||||
use dirigent_core::connectors::acp::transport::{AcpTransport, TransportResult};
|
||||
use dirigent_core::connectors::acp::protocol::ProtocolHandler;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// =========================================================================
|
||||
// Mock Transport
|
||||
// =========================================================================
|
||||
|
||||
/// A mock transport that lets tests control exactly what messages are
|
||||
/// received and when the connection "dies."
|
||||
struct MockTransport {
|
||||
/// Messages queued for recv() to return
|
||||
recv_queue: Arc<Mutex<VecDeque<TransportResult<Option<Value>>>>>,
|
||||
/// Messages captured by send()
|
||||
sent: Arc<Mutex<Vec<Value>>>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl MockTransport {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
recv_queue: Arc::new(Mutex::new(VecDeque::new())),
|
||||
sent: Arc::new(Mutex::new(Vec::new())),
|
||||
closed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a successful message to be returned by recv()
|
||||
async fn queue_message(&self, msg: Value) {
|
||||
self.recv_queue.lock().await.push_back(Ok(Some(msg)));
|
||||
}
|
||||
|
||||
/// Queue a transport error (simulates partial read / JSON parse failure)
|
||||
async fn queue_error(&self, err: &str) {
|
||||
self.recv_queue
|
||||
.lock()
|
||||
.await
|
||||
.push_back(Err(err.to_string().into()));
|
||||
}
|
||||
|
||||
/// Queue an EOF (simulates clean transport close / process exit)
|
||||
async fn queue_eof(&self) {
|
||||
self.recv_queue.lock().await.push_back(Ok(None));
|
||||
}
|
||||
|
||||
/// Get all messages that were sent through this transport
|
||||
async fn sent_messages(&self) -> Vec<Value> {
|
||||
self.sent.lock().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AcpTransport for MockTransport {
|
||||
async fn connect(&mut self) -> TransportResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send(&mut self, message: Value) -> TransportResult<()> {
|
||||
self.sent.lock().await.push(message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> TransportResult<Option<Value>> {
|
||||
let mut queue = self.recv_queue.lock().await;
|
||||
if let Some(item) = queue.pop_front() {
|
||||
item
|
||||
} else {
|
||||
// No more queued items — block forever (simulates waiting for data)
|
||||
drop(queue);
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> TransportResult<()> {
|
||||
self.closed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 1: Partial read produces transport error, not JSON parse error
|
||||
// =========================================================================
|
||||
|
||||
/// When a child process crashes mid-write, read_line() returns partial data
|
||||
/// without a trailing newline. The transport currently tries to parse this
|
||||
/// as JSON and produces a confusing "Failed to parse JSON: trailing characters"
|
||||
/// error. It SHOULD return Ok(None) — a clean EOF — instead.
|
||||
///
|
||||
/// This test uses the real StdioTransport with controlled pipes to simulate
|
||||
/// a crash mid-write.
|
||||
#[tokio::test]
|
||||
async fn test_partial_write_should_return_eof_not_parse_error() {
|
||||
use tokio::io::{AsyncWriteExt, duplex};
|
||||
|
||||
// Create a pipe pair that simulates stdout of a child process
|
||||
let (mut writer, reader) = duplex(8192);
|
||||
|
||||
// Write partial JSON (no trailing newline — crash mid-write)
|
||||
writer
|
||||
.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"019cc2e7")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Close the writer — simulates process crash (EOF after partial data)
|
||||
drop(writer);
|
||||
|
||||
// Use BufReader + read_line to simulate what StdioTransport.recv() does
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut buf_reader = tokio::io::BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
let bytes_read = buf_reader.read_line(&mut line).await.unwrap();
|
||||
|
||||
// Verify: we got partial data (bytes > 0) but no trailing newline
|
||||
assert!(bytes_read > 0, "Should have read some bytes");
|
||||
assert!(
|
||||
!line.ends_with('\n'),
|
||||
"Partial read should NOT end with newline — this is the crash signature"
|
||||
);
|
||||
|
||||
// Current behavior: serde_json::from_str fails on partial JSON
|
||||
let parse_result = serde_json::from_str::<Value>(line.trim());
|
||||
assert!(
|
||||
parse_result.is_err(),
|
||||
"Partial JSON should fail to parse"
|
||||
);
|
||||
|
||||
// The transport detects partial reads (no trailing newline) as crash
|
||||
// artifacts. This verifies the crash signature is identifiable from
|
||||
// the raw stream data.
|
||||
assert!(
|
||||
!line.ends_with('\n'),
|
||||
"Partial read without newline indicates a crash mid-write"
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 2: Dropping ProtocolHandler signals pending receivers with error
|
||||
// =========================================================================
|
||||
|
||||
/// When a ProtocolHandler is dropped while requests are pending, the oneshot
|
||||
/// senders are dropped, causing receivers to get RecvError. This test verifies
|
||||
/// that this mechanism works and demonstrates the error path.
|
||||
#[tokio::test]
|
||||
async fn test_protocol_handler_drop_signals_pending_receivers() {
|
||||
let handler = ProtocolHandler::new();
|
||||
|
||||
// Prepare a request — creates oneshot channel, stores sender in pending_requests
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "test-session",
|
||||
"message": {"role": "user", "content": {"type": "text", "text": "hello"}}
|
||||
}
|
||||
});
|
||||
|
||||
let (_message_with_id, response_rx) = handler.prepare_request(request).await;
|
||||
|
||||
// Drop the handler — this drops pending_requests, which drops the sender
|
||||
drop(handler);
|
||||
|
||||
// The receiver should immediately get an error
|
||||
let result = response_rx.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Receiver should get error when sender is dropped (handler dropped)"
|
||||
);
|
||||
|
||||
// This IS the mechanism that causes "Response channel dropped" in production.
|
||||
// The error is a bare RecvError with no context about WHY it was dropped.
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 3: Response channel error should report CONNECTION_LOST, not TIMEOUT
|
||||
// =========================================================================
|
||||
|
||||
/// When a response channel receives a cancellation from cancel_all_pending()
|
||||
/// (because the transport died), the error reported to the UI should say
|
||||
/// CONNECTION_LOST, not TIMEOUT.
|
||||
///
|
||||
/// This test simulates the full flow: prepare request → cancel_all_pending →
|
||||
/// spawned task gets structured error → emits CONNECTION_LOST.
|
||||
#[tokio::test]
|
||||
async fn test_response_channel_drop_error_should_indicate_connection_lost() {
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
let handler = ProtocolHandler::new();
|
||||
|
||||
// Prepare request (creates pending oneshot channel)
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "test-session",
|
||||
"message": {"role": "user", "content": {"type": "text", "text": "hello"}}
|
||||
}
|
||||
});
|
||||
|
||||
let (_msg, response_rx) = handler.prepare_request(request).await;
|
||||
|
||||
// Simulate what the connector does: spawn a task that waits for the response
|
||||
let (error_tx, mut error_rx) = broadcast::channel::<(String, String)>(10);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
match response_rx.await {
|
||||
Ok(response) => {
|
||||
// Check if this is an error response from cancel_all_pending
|
||||
if let Some(error) = response.get("error") {
|
||||
let error_code = "CONNECTION_LOST".to_string();
|
||||
let error_message = error.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let _ = error_tx.send((error_code, error_message));
|
||||
}
|
||||
}
|
||||
Err(_recv_error) => {
|
||||
// Fallback: bare drop without cancel_all_pending
|
||||
let _ = error_tx.send(("TIMEOUT".to_string(), "channel dropped".to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel all pending instead of just dropping
|
||||
handler.cancel_all_pending("Connection lost: agent process exited").await;
|
||||
drop(handler);
|
||||
|
||||
// Wait for the spawned task to detect the cancellation and report
|
||||
let (error_code, _error_message) = error_rx.recv().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
error_code, "CONNECTION_LOST",
|
||||
"Error code should be CONNECTION_LOST after cancel_all_pending"
|
||||
);
|
||||
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 4: Protocol handler should support explicit cancellation
|
||||
// =========================================================================
|
||||
|
||||
/// When we know the transport is dying, we can explicitly cancel all pending
|
||||
/// requests with a structured error response (containing the real reason),
|
||||
/// rather than relying on implicit Drop which gives receivers a bare
|
||||
/// RecvError with no context.
|
||||
#[tokio::test]
|
||||
async fn test_protocol_handler_should_support_explicit_cancellation() {
|
||||
let handler = ProtocolHandler::new();
|
||||
|
||||
// Prepare two concurrent requests
|
||||
let req1 = json!({"jsonrpc": "2.0", "method": "session/prompt", "params": {}});
|
||||
let req2 = json!({"jsonrpc": "2.0", "method": "session/list", "params": {}});
|
||||
|
||||
let (_msg1, response_rx1) = handler.prepare_request(req1).await;
|
||||
let (_msg2, response_rx2) = handler.prepare_request(req2).await;
|
||||
|
||||
// cancel_all_pending() now exists — call it
|
||||
handler.cancel_all_pending("Connection lost: agent process exited").await;
|
||||
|
||||
// Both receivers should get structured error responses (not bare RecvError)
|
||||
let result1 = response_rx1.await;
|
||||
let result2 = response_rx2.await;
|
||||
|
||||
assert!(result1.is_ok(), "Receiver 1 should get Ok (structured error), not Err");
|
||||
assert!(result2.is_ok(), "Receiver 2 should get Ok (structured error), not Err");
|
||||
|
||||
// Verify the error response contains the reason
|
||||
let resp1 = result1.unwrap();
|
||||
let error_obj = resp1.get("error").expect("Should have error field");
|
||||
assert_eq!(error_obj.get("code").unwrap(), -32000);
|
||||
assert!(
|
||||
error_obj.get("message").unwrap().as_str().unwrap().contains("Connection lost"),
|
||||
"Error message should contain the cancellation reason"
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 5: Full crash sequence — request in flight, transport dies
|
||||
// =========================================================================
|
||||
|
||||
/// Simulates the exact sequence from the production logs:
|
||||
/// 1. Protocol handler prepares a request
|
||||
/// 2. Request is "sent" via mock transport
|
||||
/// 3. Transport starts returning notifications (streaming chunks)
|
||||
/// 4. Transport returns error (partial read from crash)
|
||||
/// 5. Transport returns EOF (process dead)
|
||||
/// 6. Protocol handler is dropped
|
||||
/// 7. Pending response receiver gets error
|
||||
///
|
||||
/// This is the integration test that proves the full cascade.
|
||||
#[tokio::test]
|
||||
async fn test_full_crash_sequence_request_in_flight() {
|
||||
let handler = ProtocolHandler::new();
|
||||
let mut transport = MockTransport::new();
|
||||
|
||||
// Step 1: Prepare a prompt request
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "019cc2e7-26d6-7102-bed6-4c953c023109",
|
||||
"message": {"role": "user", "content": {"type": "text", "text": "hello"}}
|
||||
}
|
||||
});
|
||||
let (msg_with_id, response_rx) = handler.prepare_request(request).await;
|
||||
let request_id = msg_with_id.get("id").cloned().unwrap();
|
||||
|
||||
// Step 2: Send via transport
|
||||
transport.send(msg_with_id).await.unwrap();
|
||||
|
||||
// Step 3: Queue some streaming notifications (agent is responding)
|
||||
transport
|
||||
.queue_message(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "019cc2e7-26d6-7102-bed6-4c953c023109",
|
||||
"update": {
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {"type": "text", "text": "Hello! I'm an AI"}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Step 4: Queue a transport error (partial read from crash)
|
||||
transport
|
||||
.queue_error("Failed to parse JSON: trailing characters at line 1 column 4. Line: -26d6-7102-bed6-4c953c023109")
|
||||
.await;
|
||||
|
||||
// Step 5: Queue EOF (process dead)
|
||||
transport.queue_eof().await;
|
||||
|
||||
// Process the notification — it should be routed to notification channel
|
||||
let msg = transport.recv().await.unwrap().unwrap();
|
||||
let _result = handler.handle_message(msg).await;
|
||||
|
||||
// Process the error — transport returns Err
|
||||
let err_result = transport.recv().await;
|
||||
assert!(err_result.is_err(), "Transport should return error for partial read");
|
||||
let err_msg = err_result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("Failed to parse JSON"),
|
||||
"Error should mention JSON parse failure, got: {}",
|
||||
err_msg
|
||||
);
|
||||
|
||||
// Process the EOF
|
||||
let eof = transport.recv().await.unwrap();
|
||||
assert!(eof.is_none(), "Transport should return None for EOF");
|
||||
|
||||
// Step 6: Cancel all pending (simulates what connector does before break)
|
||||
handler.cancel_all_pending("Connection lost: agent process exited").await;
|
||||
drop(handler);
|
||||
|
||||
// Step 7: The pending response receiver should get a structured error
|
||||
let result = response_rx.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Pending request should receive structured error response from cancel_all_pending"
|
||||
);
|
||||
|
||||
let response = result.unwrap();
|
||||
let error = response.get("error").expect("Should have error field");
|
||||
assert_eq!(error.get("code").unwrap(), -32000);
|
||||
assert!(
|
||||
error.get("message").unwrap().as_str().unwrap().contains("Connection lost"),
|
||||
"Error should contain crash reason"
|
||||
);
|
||||
|
||||
// Verify the request was actually sent
|
||||
let sent = transport.sent_messages().await;
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0].get("id"), Some(&request_id));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 6: Crash context — stderr, exit status, partial stdout should be
|
||||
// assembled into a single diagnostic report
|
||||
// =========================================================================
|
||||
|
||||
/// When a child process crashes, three separate data sources contain the
|
||||
/// explanation: stderr (panic/error message), exit status (signal/code),
|
||||
/// and partial stdout (what was being written). Currently these are in
|
||||
/// separate silos and never assembled together.
|
||||
///
|
||||
/// This test simulates a real child process crash and verifies that:
|
||||
/// - stderr output IS captured (currently: drained to warn! log, then lost)
|
||||
/// - exit status IS available at crash time (currently: only in close())
|
||||
/// - partial stdout IS identified as crash artifact (currently: JSON parse error)
|
||||
#[tokio::test]
|
||||
async fn test_crash_context_should_capture_stderr_and_exit_status() {
|
||||
// Simulate a child process crash where three data sources exist:
|
||||
// 1. Writes partial JSON to stdout (simulates crash mid-write)
|
||||
// 2. Writes an error message to stderr (simulates panic/error output)
|
||||
// 3. Exits with code 1
|
||||
//
|
||||
// We use a simple inline script via the shell.
|
||||
// Use tokio duplex streams to simulate a crashing child process
|
||||
// without needing an external program. This is more reliable than
|
||||
// spawning shell commands across platforms.
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// Simulate stdout: partial JSON without trailing newline
|
||||
let (mut stdout_writer, stdout_read) = tokio::io::duplex(8192);
|
||||
stdout_writer
|
||||
.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"crash")
|
||||
.await
|
||||
.unwrap();
|
||||
stdout_writer.flush().await.unwrap();
|
||||
drop(stdout_writer); // EOF — simulates process death
|
||||
|
||||
// Simulate stderr: error message from the crashing process
|
||||
let (mut stderr_writer, stderr_read) = tokio::io::duplex(8192);
|
||||
stderr_writer
|
||||
.write_all(b"FATAL: panicked at 'index out of bounds', src/main.rs:42\n")
|
||||
.await
|
||||
.unwrap();
|
||||
stderr_writer.flush().await.unwrap();
|
||||
drop(stderr_writer); // EOF
|
||||
|
||||
// Read stdout — should get partial data (no trailing newline)
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut stdout_reader = tokio::io::BufReader::new(stdout_read);
|
||||
let mut stdout_line = String::new();
|
||||
let bytes = stdout_reader.read_line(&mut stdout_line).await.unwrap();
|
||||
|
||||
// Read stderr — should get the error message
|
||||
let mut stderr_reader = tokio::io::BufReader::new(stderr_read);
|
||||
let mut stderr_line = String::new();
|
||||
let _ = stderr_reader.read_line(&mut stderr_line).await.unwrap();
|
||||
|
||||
// VERIFY: All three data sources are available in principle
|
||||
assert!(bytes > 0, "Should have stdout data");
|
||||
assert!(
|
||||
!stdout_line.ends_with('\n'),
|
||||
"Partial stdout should NOT end with newline (crash mid-write)"
|
||||
);
|
||||
assert!(
|
||||
stderr_line.contains("FATAL") || stderr_line.contains("panicked"),
|
||||
"Stderr should contain the error reason, got: {}",
|
||||
stderr_line
|
||||
);
|
||||
|
||||
// Verify that the partial stdout is NOT valid JSON
|
||||
let parse_result = serde_json::from_str::<Value>(&stdout_line.trim());
|
||||
assert!(
|
||||
parse_result.is_err(),
|
||||
"Partial stdout should not parse as valid JSON"
|
||||
);
|
||||
|
||||
// Demonstrate that CrashContext can be assembled from these data sources.
|
||||
// StdioTransport now exposes get_crash_context() which collects stderr,
|
||||
// exit status, and partial stdout into a single diagnostic struct.
|
||||
use dirigent_core::connectors::acp::transport::CrashContext;
|
||||
|
||||
let crash_ctx = CrashContext {
|
||||
recent_stderr: vec![stderr_line.trim().to_string()],
|
||||
exit_status: None, // duplex streams don't have exit status
|
||||
partial_stdout: Some(stdout_line.clone()),
|
||||
};
|
||||
|
||||
assert!(!crash_ctx.recent_stderr.is_empty(), "CrashContext should contain stderr");
|
||||
assert!(crash_ctx.partial_stdout.is_some(), "CrashContext should contain partial stdout");
|
||||
assert!(
|
||||
crash_ctx.recent_stderr[0].contains("FATAL"),
|
||||
"Stderr in CrashContext should contain the crash reason"
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 7: Response arrives correctly when transport is healthy
|
||||
// =========================================================================
|
||||
|
||||
/// Baseline test: verify the happy path works — request sent, response
|
||||
/// arrives, oneshot channel delivers it correctly.
|
||||
#[tokio::test]
|
||||
async fn test_happy_path_response_delivered_correctly() {
|
||||
let handler = ProtocolHandler::new();
|
||||
|
||||
// Prepare request
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/new",
|
||||
"params": {"cwd": "."}
|
||||
});
|
||||
let (msg_with_id, response_rx) = handler.prepare_request(request).await;
|
||||
let request_id = msg_with_id.get("id").cloned().unwrap();
|
||||
|
||||
// Simulate response arriving (what handle_message does when response comes)
|
||||
let response = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request_id,
|
||||
"result": {"sessionId": "new-session-123"}
|
||||
});
|
||||
handler.handle_message(response).await;
|
||||
|
||||
// Response should be delivered via oneshot
|
||||
let result = response_rx.await;
|
||||
assert!(result.is_ok(), "Response should be delivered successfully");
|
||||
|
||||
let response_value = result.unwrap();
|
||||
assert_eq!(
|
||||
response_value
|
||||
.get("result")
|
||||
.unwrap()
|
||||
.get("sessionId")
|
||||
.unwrap(),
|
||||
"new-session-123"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
//! TEST-002: Comprehensive HTTP Integration Tests
|
||||
//!
|
||||
//! This file contains comprehensive integration tests for the ACP client over HTTP transport.
|
||||
//! It tests the full lifecycle of ACP communication using the dirigate HTTP server.
|
||||
//!
|
||||
//! Test Coverage:
|
||||
//! - T009: Full HTTP lifecycle (initialize, new session, prompt, cancel)
|
||||
//! - T010: HTTP initialize handshake
|
||||
//! - T011: HTTP session creation
|
||||
//! - T012: HTTP message sending with SSE streaming
|
||||
//! - T013: HTTP session cancellation
|
||||
//! - T014: SSE connection and events
|
||||
//! - T015: Multiple concurrent HTTP sessions
|
||||
//! - T016: HTTP connection recovery
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
use dirigent_core::connectors::acp::{AcpConfig, AcpConnector};
|
||||
use dirigent_core::connectors::{Connector, ConnectorCommand};
|
||||
use dirigent_protocol::Event;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Helper to get test port (unique per test to avoid conflicts)
|
||||
fn test_port(offset: u16) -> u16 {
|
||||
9000 + offset
|
||||
}
|
||||
|
||||
/// Helper to create a test fixture path
|
||||
fn test_fixture_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join("acp_test.yaml")
|
||||
}
|
||||
|
||||
/// Helper to spawn the mocker HTTP server in the background
|
||||
async fn spawn_mocker_server(port: u16) -> anyhow::Result<tokio::task::JoinHandle<()>> {
|
||||
let fixture_path = test_fixture_path();
|
||||
|
||||
// Build the mocker
|
||||
tracing::info!("Building dirigate...");
|
||||
let build_status = tokio::process::Command::new("cargo")
|
||||
.args(&["build", "--package", "dirigate"])
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
if !build_status.success() {
|
||||
anyhow::bail!("Failed to build dirigate");
|
||||
}
|
||||
|
||||
// Find the binary
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
|
||||
let binary_path = project_root
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join("dirigate.exe");
|
||||
|
||||
if !binary_path.exists() {
|
||||
anyhow::bail!("Mocker binary not found at {:?}", binary_path);
|
||||
}
|
||||
|
||||
// Spawn the server
|
||||
tracing::info!("Starting mocker server on port {}", port);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = tokio::process::Command::new(&binary_path)
|
||||
.args(&[
|
||||
"serve",
|
||||
"--fixtures",
|
||||
fixture_path.to_str().unwrap(),
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
])
|
||||
.status()
|
||||
.await;
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Helper to create a test ACP connector with HTTP transport
|
||||
fn create_http_connector(port: u16) -> AcpConnector {
|
||||
let config = AcpConfig::http(format!("http://127.0.0.1:{}", port))
|
||||
.with_retry_max_attempts(3)
|
||||
.with_retry_initial_delay(Duration::from_millis(500));
|
||||
|
||||
AcpConnector::new(
|
||||
format!("http-test-connector-{}", port),
|
||||
uuid::Uuid::nil(),
|
||||
"HTTP Test Connector".to_string(),
|
||||
config,
|
||||
dirigent_core::sharing::bus::SharingBus::new(),
|
||||
)
|
||||
.expect("Failed to create connector")
|
||||
}
|
||||
|
||||
/// Helper to wait for event with timeout
|
||||
async fn wait_for_event<F>(
|
||||
events_rx: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
predicate: F,
|
||||
timeout_duration: Duration,
|
||||
) -> anyhow::Result<Event>
|
||||
where
|
||||
F: Fn(&Event) -> bool,
|
||||
{
|
||||
let result = timeout(timeout_duration, async {
|
||||
loop {
|
||||
match events_rx.recv().await {
|
||||
Ok(event) => {
|
||||
if predicate(&event) {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Event receive error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => Ok(event),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(anyhow::anyhow!("Timeout waiting for event")),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T009: Full HTTP Lifecycle Test
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t009_full_lifecycle_http() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(1);
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
// Create connector
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector task
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
let connected_event = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(connected_event, Event::Connected));
|
||||
tracing::info!("✓ Connected to HTTP mocker");
|
||||
|
||||
// Create a new session
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_created = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_id = match session_created {
|
||||
Event::SessionCreated { session, .. } => {
|
||||
tracing::info!("✓ Session created: {}", session.id);
|
||||
session.id
|
||||
}
|
||||
_ => anyhow::bail!("Expected SessionCreated event"),
|
||||
};
|
||||
|
||||
// Send a message
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Hello via HTTP!".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for message started
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!("✓ Message started");
|
||||
|
||||
// Wait for some content
|
||||
let mut received_content = false;
|
||||
for _ in 0..10 {
|
||||
if let Ok(event) = timeout(Duration::from_secs(2), events.recv()).await {
|
||||
match event? {
|
||||
Event::SessionUpdate { .. } => {
|
||||
received_content = true;
|
||||
tracing::info!("✓ Received content via SSE");
|
||||
break;
|
||||
}
|
||||
Event::MessageCompleted { .. } => {
|
||||
received_content = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(received_content, "Should receive content via SSE");
|
||||
|
||||
// Stop the connector
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
tracing::info!("✓ Full HTTP lifecycle completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T010: HTTP Initialize Handshake
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t010_http_initialize() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(2);
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connected event
|
||||
let connected = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(connected, Event::Connected));
|
||||
tracing::info!("✓ HTTP initialize handshake completed");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T011: HTTP Session Creation
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t011_http_session_creation() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(3);
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create session
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for session created event
|
||||
let session_created = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match session_created {
|
||||
Event::SessionCreated { session, .. } => {
|
||||
assert_eq!(session.title, "HTTP Session Test");
|
||||
tracing::info!("✓ HTTP session created with correct title");
|
||||
}
|
||||
_ => anyhow::bail!("Expected SessionCreated event"),
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T012: HTTP Message Sending with SSE Streaming
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t012_http_sse_streaming() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(4);
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Setup
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send message
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Stream me some content".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for message started
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Collect SSE chunks
|
||||
let mut chunk_count = 0;
|
||||
let mut completed = false;
|
||||
|
||||
for _ in 0..20 {
|
||||
if let Ok(result) = timeout(Duration::from_secs(2), events.recv()).await {
|
||||
match result? {
|
||||
Event::SessionUpdate { update, .. } => {
|
||||
if let dirigent_protocol::SessionUpdate::AgentMessageChunk { .. } = update {
|
||||
chunk_count += 1;
|
||||
tracing::info!("Received SSE chunk {}", chunk_count);
|
||||
}
|
||||
}
|
||||
Event::MessageCompleted { .. } => {
|
||||
completed = true;
|
||||
tracing::info!("✓ Message completed via SSE after {} chunks", chunk_count);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(chunk_count > 0, "Should receive SSE chunks");
|
||||
assert!(completed, "Message should complete via SSE");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T013: HTTP Session Cancellation
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t013_http_cancellation() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(5);
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Setup
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send message
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Long response".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for streaming to start
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionUpdate { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Cancel
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CancelGeneration {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!("✓ HTTP cancellation sent successfully");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T014: SSE Connection and Events
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t014_sse_connection() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(6);
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// The SSE connection is established during initialization
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!("✓ SSE connection established");
|
||||
|
||||
// Create a session and send a message to verify SSE delivery
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id,
|
||||
text: "Test SSE".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Verify we receive events via SSE
|
||||
let mut received_via_sse = false;
|
||||
for _ in 0..10 {
|
||||
if let Ok(event) = timeout(Duration::from_secs(1), events.recv()).await {
|
||||
match event? {
|
||||
Event::SessionUpdate { .. }
|
||||
| Event::MessageStarted { .. }
|
||||
| Event::MessageCompleted { .. } => {
|
||||
received_via_sse = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(received_via_sse, "Should receive events via SSE");
|
||||
tracing::info!("✓ SSE event delivery verified");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T015: Multiple Concurrent HTTP Sessions
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t015_http_multiple_sessions() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(7);
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cmd_tx = connector.command_tx();
|
||||
let mut session_ids = Vec::new();
|
||||
|
||||
// Create multiple sessions via HTTP
|
||||
for _i in 0..3 {
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
session_ids.push(session_id);
|
||||
}
|
||||
|
||||
assert_eq!(session_ids.len(), 3);
|
||||
tracing::info!("✓ Created 3 concurrent HTTP sessions");
|
||||
|
||||
// Send messages to all sessions
|
||||
for session_id in &session_ids {
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Test".to_string(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Wait for responses
|
||||
let mut started_count = 0;
|
||||
for _ in 0..30 {
|
||||
if let Ok(result) = timeout(Duration::from_secs(1), events.recv()).await {
|
||||
if let Event::MessageStarted { .. } = result? {
|
||||
started_count += 1;
|
||||
if started_count == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(started_count, 3);
|
||||
tracing::info!("✓ All HTTP messages received responses");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T016: HTTP Connection Recovery
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires building mocker and port availability"]
|
||||
async fn test_t016_http_connection_recovery() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let port = test_port(8);
|
||||
|
||||
// Create connector first (without server running)
|
||||
let connector = create_http_connector(port);
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait a bit for failed connection attempts
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Now start the server
|
||||
tracing::info!("Starting server after connector creation");
|
||||
let _server_handle = spawn_mocker_server(port).await?;
|
||||
|
||||
// Should eventually connect (with retry)
|
||||
let connected = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(connected, Event::Connected));
|
||||
tracing::info!("✓ HTTP connector recovered and connected");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Integration tests for ACP initialization and capability negotiation.
|
||||
//!
|
||||
//! These tests verify that the ACP protocol implementation correctly performs:
|
||||
//! - Protocol version negotiation
|
||||
//! - Capability exchange
|
||||
//! - Optional authentication
|
||||
//! - Capability validation
|
||||
//!
|
||||
//! Tests use the dirigate for both stdio and HTTP transport modes.
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod initialization_tests {
|
||||
use dirigent_core::acp::{
|
||||
authenticate, capabilities, connector_state::*, initialize,
|
||||
};
|
||||
|
||||
/// Test helper to create default client info.
|
||||
fn client_info() -> ImplementationInfo {
|
||||
ImplementationInfo {
|
||||
name: "dirigent-test".to_string(),
|
||||
title: Some("Dirigent Test Client".to_string()),
|
||||
version: Some("0.1.0".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initialize_request_serialization() {
|
||||
let caps = ClientCapabilities::default_safe();
|
||||
let request = initialize::InitializeRequest::new(caps, Some(client_info()));
|
||||
|
||||
// Verify serialization
|
||||
let jsonrpc = request.to_jsonrpc(1);
|
||||
assert_eq!(jsonrpc.method, "initialize");
|
||||
assert_eq!(jsonrpc.jsonrpc, "2.0");
|
||||
assert!(jsonrpc.params.is_some());
|
||||
|
||||
// Verify the serialized params contain expected fields
|
||||
let params = jsonrpc.params.unwrap();
|
||||
assert!(params.get("protocol_version").is_some());
|
||||
assert!(params.get("client_capabilities").is_some());
|
||||
assert!(params.get("client_info").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_initialize_response_parsing() {
|
||||
let json = serde_json::json!({
|
||||
"protocol_version": 1,
|
||||
"agent_capabilities": {
|
||||
"load_session": true,
|
||||
"prompt_capabilities": {
|
||||
"image": true,
|
||||
"audio": false,
|
||||
"embedded_context": true
|
||||
}
|
||||
},
|
||||
"agent_info": {
|
||||
"name": "test-agent",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"auth_methods": []
|
||||
});
|
||||
|
||||
let response = initialize::InitializeResponse::from_jsonrpc(&json).unwrap();
|
||||
|
||||
assert_eq!(response.protocol_version, 1);
|
||||
assert!(response.agent_capabilities.supports_load_session());
|
||||
assert!(response.agent_capabilities.supports_image());
|
||||
assert!(!response.agent_capabilities.supports_audio());
|
||||
assert!(response.agent_capabilities.supports_embedded_context());
|
||||
assert_eq!(response.auth_methods.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_version_mismatch_detection() {
|
||||
let client_version = 1;
|
||||
let agent_version = 2;
|
||||
|
||||
assert!(!initialize::is_version_compatible(
|
||||
client_version,
|
||||
agent_version
|
||||
));
|
||||
assert!(initialize::is_version_compatible(client_version, 1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_authenticate_request_redacts_credentials() {
|
||||
let creds = serde_json::json!({"api_key": "super_secret_key_12345"});
|
||||
let request = authenticate::AuthenticateRequest::new("api_key", creds);
|
||||
|
||||
let debug_str = format!("{:?}", request);
|
||||
|
||||
// Should NOT contain the actual secret
|
||||
assert!(!debug_str.contains("super_secret_key"));
|
||||
assert!(!debug_str.contains("12345"));
|
||||
|
||||
// Should show that it's redacted
|
||||
assert!(debug_str.contains("REDACTED"));
|
||||
|
||||
// Should still show the method
|
||||
assert!(debug_str.contains("api_key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_authenticate_response_parsing() {
|
||||
let success_json = serde_json::json!({
|
||||
"success": true
|
||||
});
|
||||
|
||||
let response = authenticate::AuthenticateResponse::from_jsonrpc(&success_json).unwrap();
|
||||
assert!(response.success);
|
||||
assert!(response.error.is_none());
|
||||
|
||||
let failure_json = serde_json::json!({
|
||||
"success": false,
|
||||
"error": "Invalid credentials"
|
||||
});
|
||||
|
||||
let response = authenticate::AuthenticateResponse::from_jsonrpc(&failure_json).unwrap();
|
||||
assert!(!response.success);
|
||||
assert_eq!(response.error, Some("Invalid credentials".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_auth_required_check() {
|
||||
assert!(!authenticate::is_auth_required(&[]));
|
||||
assert!(authenticate::is_auth_required(&["api_key".to_string()]));
|
||||
assert!(authenticate::is_auth_required(&[
|
||||
"api_key".to_string(),
|
||||
"oauth".to_string()
|
||||
]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_capability_validation_fs_read() {
|
||||
let caps_enabled = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = capabilities::validate_capability("fs/read_text_file", &caps_enabled);
|
||||
assert_eq!(result, capabilities::CapabilityValidation::Supported);
|
||||
|
||||
let caps_disabled = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(false),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = capabilities::validate_capability("fs/read_text_file", &caps_disabled);
|
||||
assert!(matches!(
|
||||
result,
|
||||
capabilities::CapabilityValidation::Unsupported(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_capability_validation_fs_write() {
|
||||
let caps_enabled = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(true),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = capabilities::validate_capability("fs/write_text_file", &caps_enabled);
|
||||
assert_eq!(result, capabilities::CapabilityValidation::Supported);
|
||||
|
||||
let caps_disabled = ClientCapabilities {
|
||||
fs: Some(FsCapabilities {
|
||||
read_text_file: Some(true),
|
||||
write_text_file: Some(false),
|
||||
}),
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = capabilities::validate_capability("fs/write_text_file", &caps_disabled);
|
||||
assert!(matches!(
|
||||
result,
|
||||
capabilities::CapabilityValidation::Unsupported(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_capability_validation_terminal() {
|
||||
let caps_enabled = ClientCapabilities {
|
||||
fs: None,
|
||||
terminal: Some(true),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = capabilities::validate_capability("terminal/execute", &caps_enabled);
|
||||
assert_eq!(result, capabilities::CapabilityValidation::Supported);
|
||||
|
||||
let result = capabilities::validate_capability("terminal/read", &caps_enabled);
|
||||
assert_eq!(result, capabilities::CapabilityValidation::Supported);
|
||||
|
||||
let caps_disabled = ClientCapabilities {
|
||||
fs: None,
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let result = capabilities::validate_capability("terminal/execute", &caps_disabled);
|
||||
assert!(matches!(
|
||||
result,
|
||||
capabilities::CapabilityValidation::Unsupported(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_capability_validation_core_methods() {
|
||||
let minimal_caps = ClientCapabilities {
|
||||
fs: None,
|
||||
terminal: Some(false),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// Core protocol methods should always be supported
|
||||
assert_eq!(
|
||||
capabilities::validate_capability("initialize", &minimal_caps),
|
||||
capabilities::CapabilityValidation::Supported
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities::validate_capability("authenticate", &minimal_caps),
|
||||
capabilities::CapabilityValidation::Supported
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities::validate_capability("session/new", &minimal_caps),
|
||||
capabilities::CapabilityValidation::Supported
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities::validate_capability("session/prompt", &minimal_caps),
|
||||
capabilities::CapabilityValidation::Supported
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_requires_capability_check() {
|
||||
assert!(capabilities::requires_capability_check("fs/read_text_file"));
|
||||
assert!(capabilities::requires_capability_check("fs/write_text_file"));
|
||||
assert!(capabilities::requires_capability_check("terminal/execute"));
|
||||
|
||||
assert!(!capabilities::requires_capability_check("initialize"));
|
||||
assert!(!capabilities::requires_capability_check("authenticate"));
|
||||
assert!(!capabilities::requires_capability_check("session/new"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_state_lifecycle() {
|
||||
let mut state = AcpConnectorState::new();
|
||||
|
||||
assert_eq!(state.connection_state, ConnectionState::Uninitialized);
|
||||
assert!(!state.is_ready());
|
||||
|
||||
state.connection_state = ConnectionState::Initializing;
|
||||
assert!(!state.is_ready());
|
||||
|
||||
state.connection_state = ConnectionState::Initialized;
|
||||
assert!(!state.is_ready());
|
||||
|
||||
state.connection_state = ConnectionState::Ready;
|
||||
assert!(state.is_ready());
|
||||
|
||||
state.connection_state = ConnectionState::Disconnected;
|
||||
assert!(!state.is_ready());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_state_auth_required() {
|
||||
let mut state = AcpConnectorState::new();
|
||||
|
||||
assert!(!state.requires_auth());
|
||||
|
||||
state.auth_methods = vec!["api_key".to_string()];
|
||||
assert!(state.requires_auth());
|
||||
|
||||
state.authenticated = true;
|
||||
assert!(!state.requires_auth());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connector_state_error() {
|
||||
let mut state = AcpConnectorState::new();
|
||||
|
||||
state.set_error("Test error");
|
||||
|
||||
match state.connection_state {
|
||||
ConnectionState::Error(msg) => {
|
||||
assert_eq!(msg, "Test error");
|
||||
}
|
||||
_ => panic!("Expected Error state"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_capability_not_supported_error() {
|
||||
let error = capabilities::capability_not_supported_error(
|
||||
"fs/write_text_file",
|
||||
"write capability not enabled",
|
||||
);
|
||||
|
||||
assert_eq!(error.code, capabilities::ERROR_METHOD_NOT_FOUND);
|
||||
assert!(error.message.contains("fs/write_text_file"));
|
||||
assert!(error.message.contains("write capability not enabled"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_capabilities_helpers() {
|
||||
let caps = AgentCapabilities {
|
||||
load_session: Some(true),
|
||||
prompt_capabilities: Some(PromptCapabilities {
|
||||
image: Some(true),
|
||||
audio: Some(false),
|
||||
embedded_context: Some(true),
|
||||
}),
|
||||
mcp: Some(McpCapabilities {
|
||||
http: Some(true),
|
||||
sse: Some(false),
|
||||
}),
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
assert!(caps.supports_load_session());
|
||||
assert!(caps.supports_image());
|
||||
assert!(!caps.supports_audio());
|
||||
assert!(caps.supports_embedded_context());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_capabilities_presets() {
|
||||
let safe_caps = ClientCapabilities::default_safe();
|
||||
assert_eq!(
|
||||
safe_caps.fs.as_ref().unwrap().read_text_file,
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
safe_caps.fs.as_ref().unwrap().write_text_file,
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(safe_caps.terminal, Some(false));
|
||||
|
||||
let all_caps = ClientCapabilities::all_enabled();
|
||||
assert_eq!(all_caps.fs.as_ref().unwrap().read_text_file, Some(true));
|
||||
assert_eq!(all_caps.fs.as_ref().unwrap().write_text_file, Some(true));
|
||||
assert_eq!(all_caps.terminal, Some(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
# ACP Integration Test Environment
|
||||
|
||||
This directory contains utilities and fixtures for integration testing with the `dirigent_acp_mocker`.
|
||||
|
||||
## Structure
|
||||
|
||||
- **mocker_utils.rs** - Utilities for spawning and managing mocker processes
|
||||
- **golden_transcripts.rs** - Golden transcript fixtures for common ACP flows
|
||||
- **README.md** - This file
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Tests (No Process Spawning)
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test --package dirigent_core --test acp_mocker_test
|
||||
|
||||
# Run specific test
|
||||
cargo test --package dirigent_core --test acp_mocker_test test_golden_transcripts_available
|
||||
```
|
||||
|
||||
### Integration Tests (With Process Spawning)
|
||||
|
||||
These tests spawn actual mocker processes and are ignored by default.
|
||||
|
||||
```bash
|
||||
# Run all ignored tests (spawns processes)
|
||||
cargo test --package dirigent_core --test acp_mocker_test -- --ignored
|
||||
|
||||
# Run specific ignored test
|
||||
cargo test --package dirigent_core --test acp_mocker_test test_spawn_stdio_mocker -- --ignored
|
||||
|
||||
# Run all tests (including ignored ones)
|
||||
cargo test --package dirigent_core --test acp_mocker_test -- --include-ignored
|
||||
```
|
||||
|
||||
## Mocker Utilities
|
||||
|
||||
### Spawning a Mocker in Stdio Mode
|
||||
|
||||
```rust
|
||||
use dirigent_core::tests::acp_integration::MockerProcess;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_with_stdio_mocker() {
|
||||
let mocker = MockerProcess::spawn_stdio().await.unwrap();
|
||||
|
||||
// Use mocker via stdin/stdout...
|
||||
|
||||
mocker.kill().await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
### Spawning a Mocker in HTTP Mode
|
||||
|
||||
```rust
|
||||
use dirigent_core::tests::acp_integration::MockerProcess;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_with_http_mocker() {
|
||||
let port = 18888;
|
||||
let mocker = MockerProcess::spawn_http(port).await.unwrap();
|
||||
|
||||
// Connect to mocker at http://localhost:18888
|
||||
|
||||
mocker.kill().await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
### Using Configuration Presets
|
||||
|
||||
```rust
|
||||
use dirigent_core::tests::acp_integration::{MockerProcess, MockerConfig};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_with_configured_mocker() {
|
||||
let config = MockerConfig::with_preset("basic");
|
||||
let args: Vec<&str> = config.to_args().iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let mocker = MockerProcess::spawn_stdio_with_args(&args).await.unwrap();
|
||||
|
||||
// Use mocker...
|
||||
|
||||
mocker.kill().await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
## Golden Transcripts
|
||||
|
||||
Golden transcripts represent expected request/response sequences for testing.
|
||||
|
||||
```rust
|
||||
use dirigent_core::tests::acp_integration::load_golden_transcript;
|
||||
|
||||
#[test]
|
||||
fn test_with_golden_transcript() {
|
||||
let transcript = load_golden_transcript("initialize").unwrap();
|
||||
|
||||
let request = &transcript["request"];
|
||||
let response = &transcript["response"];
|
||||
|
||||
// Validate against actual mocker responses...
|
||||
}
|
||||
```
|
||||
|
||||
### Available Transcripts
|
||||
|
||||
- **initialize** - Initialize handshake with capabilities exchange
|
||||
- **new_session** - Create a new session
|
||||
- **prompt** - Send a simple prompt and receive streaming response
|
||||
- **tool_call_read** - Tool call flow for reading a file
|
||||
- **cancel** - Cancel a running session
|
||||
|
||||
## Windows-Specific Notes
|
||||
|
||||
- Process spawning uses `cargo run` to build and run the mocker
|
||||
- Paths are handled cross-platform by default
|
||||
- Process cleanup is automatic via Drop implementation
|
||||
- If tests hang, check for orphaned mocker processes in Task Manager
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Mocker Won't Start
|
||||
|
||||
1. Ensure `dirigent_acp_mocker` package builds:
|
||||
```bash
|
||||
cargo build --package dirigent_acp_mocker
|
||||
```
|
||||
|
||||
2. Try running the mocker manually:
|
||||
```bash
|
||||
cargo run --package dirigent_acp_mocker -- serve --stdio
|
||||
```
|
||||
|
||||
3. Check for port conflicts (HTTP mode):
|
||||
```bash
|
||||
netstat -ano | findstr :18888
|
||||
```
|
||||
|
||||
### Tests Timeout
|
||||
|
||||
- Increase the timeout duration in the test
|
||||
- Check mocker logs for errors (stderr is inherited)
|
||||
- Verify mocker is actually starting (add debug logging)
|
||||
|
||||
### Process Cleanup Issues
|
||||
|
||||
- The `Drop` implementation should clean up automatically
|
||||
- If orphaned processes remain, kill them manually:
|
||||
```bash
|
||||
# Windows
|
||||
taskkill /F /IM dirigent_acp_mocker.exe
|
||||
|
||||
# Linux/macOS
|
||||
pkill -f dirigent_acp_mocker
|
||||
```
|
||||
|
||||
## Future Work
|
||||
|
||||
This infrastructure is ready for:
|
||||
|
||||
- **TEST-01**: Protocol validation tests (stdio mode)
|
||||
- **TEST-02**: Protocol validation tests (HTTP mode)
|
||||
- **TEST-03**: Session update rendering tests
|
||||
- **TEST-04**: Permission prompt flow tests
|
||||
- **TEST-05**: File operations sandbox tests
|
||||
- **TEST-06**: Terminal lifecycle tests
|
||||
- **TEST-07**: Search operations tests
|
||||
|
||||
See `docs/building/04_acp_client/04_tasks_00_scaffolding_and_finishing.md` for the full test plan.
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Golden transcript fixtures for common ACP flows.
|
||||
//!
|
||||
//! These fixtures represent expected request/response sequences for testing.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
/// Golden transcript for initialize flow.
|
||||
pub fn golden_initialize() -> serde_json::Value {
|
||||
json!({
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"capabilities": {
|
||||
"tools": true,
|
||||
"streaming": true
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "dirigent",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"capabilities": {
|
||||
"tools": ["read", "write", "edit", "search", "execute"],
|
||||
"streaming": true,
|
||||
"embeddedContext": true
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "dirigate",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Golden transcript for new_session flow.
|
||||
pub fn golden_new_session() -> serde_json::Value {
|
||||
json!({
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"mode": "ask"
|
||||
},
|
||||
"id": 2
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"sessionId": "test-session-123"
|
||||
},
|
||||
"id": 2
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Golden transcript for simple prompt flow.
|
||||
pub fn golden_prompt() -> serde_json::Value {
|
||||
json!({
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello, agent!"
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
"id": 3
|
||||
},
|
||||
"streaming_updates": [
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"type": "agent_message_chunk",
|
||||
"content": {
|
||||
"text": "Hello! How can I help you?"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {},
|
||||
"id": 3
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Golden transcript for tool call flow (read file).
|
||||
pub fn golden_tool_call_read() -> serde_json::Value {
|
||||
json!({
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Read the file test.txt"
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
"id": 4
|
||||
},
|
||||
"streaming_updates": [
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"type": "tool_call",
|
||||
"content": {
|
||||
"toolCallId": "tool-1",
|
||||
"kind": "read",
|
||||
"title": "Read test.txt",
|
||||
"location": {
|
||||
"path": "/path/to/test.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"type": "tool_call_update",
|
||||
"content": {
|
||||
"toolCallId": "tool-1",
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"type": "content",
|
||||
"content": "File content here"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "test-session-123",
|
||||
"type": "agent_message_chunk",
|
||||
"content": {
|
||||
"text": "I've read the file. The content is: ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {},
|
||||
"id": 4
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Golden transcript for cancellation flow.
|
||||
pub fn golden_cancel() -> serde_json::Value {
|
||||
json!({
|
||||
"request": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/cancel",
|
||||
"params": {
|
||||
"sessionId": "test-session-123"
|
||||
},
|
||||
"id": 5
|
||||
},
|
||||
"response": {
|
||||
"jsonrpc": "2.0",
|
||||
"result": {},
|
||||
"id": 5
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a golden transcript by name.
|
||||
pub fn load_golden_transcript(name: &str) -> Option<serde_json::Value> {
|
||||
match name {
|
||||
"initialize" => Some(golden_initialize()),
|
||||
"new_session" => Some(golden_new_session()),
|
||||
"prompt" => Some(golden_prompt()),
|
||||
"tool_call_read" => Some(golden_tool_call_read()),
|
||||
"cancel" => Some(golden_cancel()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_golden_transcripts_exist() {
|
||||
assert!(load_golden_transcript("initialize").is_some());
|
||||
assert!(load_golden_transcript("new_session").is_some());
|
||||
assert!(load_golden_transcript("prompt").is_some());
|
||||
assert!(load_golden_transcript("tool_call_read").is_some());
|
||||
assert!(load_golden_transcript("cancel").is_some());
|
||||
assert!(load_golden_transcript("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_golden_initialize_structure() {
|
||||
let transcript = golden_initialize();
|
||||
assert!(transcript.get("request").is_some());
|
||||
assert!(transcript.get("response").is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Utilities for spawning and managing the ACP mocker in tests.
|
||||
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// Handle to a running mocker process.
|
||||
pub struct MockerProcess {
|
||||
process: Child,
|
||||
mode: MockerMode,
|
||||
}
|
||||
|
||||
/// Mocker execution mode.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MockerMode {
|
||||
/// Stdio mode (stdin/stdout communication)
|
||||
Stdio,
|
||||
/// HTTP mode (HTTP + SSE communication)
|
||||
Http { port: u16 },
|
||||
}
|
||||
|
||||
impl MockerProcess {
|
||||
/// Spawn a mocker in stdio mode.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// use dirigent_core::tests::acp_integration::MockerProcess;
|
||||
///
|
||||
/// #[tokio::test]
|
||||
/// async fn test_stdio_mocker() {
|
||||
/// let mocker = MockerProcess::spawn_stdio().await.unwrap();
|
||||
/// // Use mocker...
|
||||
/// mocker.kill().await.unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn spawn_stdio() -> Result<Self, String> {
|
||||
Self::spawn_stdio_with_args(&[]).await
|
||||
}
|
||||
|
||||
/// Spawn a mocker in stdio mode with custom arguments.
|
||||
pub async fn spawn_stdio_with_args(args: &[&str]) -> Result<Self, String> {
|
||||
let mut cmd_args = vec!["serve", "--stdio"];
|
||||
cmd_args.extend_from_slice(args);
|
||||
|
||||
let process = Command::new("cargo")
|
||||
.args(&["run", "--package", "dirigate", "--"])
|
||||
.args(&cmd_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn mocker: {}", e))?;
|
||||
|
||||
// Give the mocker time to start
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
Ok(Self {
|
||||
process,
|
||||
mode: MockerMode::Stdio,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a mocker in HTTP mode on a specific port.
|
||||
///
|
||||
/// # Example
|
||||
/// ```no_run
|
||||
/// use dirigent_core::tests::acp_integration::MockerProcess;
|
||||
///
|
||||
/// #[tokio::test]
|
||||
/// async fn test_http_mocker() {
|
||||
/// let mocker = MockerProcess::spawn_http(8888).await.unwrap();
|
||||
/// // Use mocker at http://localhost:8888
|
||||
/// mocker.kill().await.unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn spawn_http(port: u16) -> Result<Self, String> {
|
||||
Self::spawn_http_with_args(port, &[]).await
|
||||
}
|
||||
|
||||
/// Spawn a mocker in HTTP mode with custom arguments.
|
||||
pub async fn spawn_http_with_args(port: u16, args: &[&str]) -> Result<Self, String> {
|
||||
let port_str = port.to_string();
|
||||
let mut cmd_args = vec!["serve", "--port", port_str.as_str()];
|
||||
cmd_args.extend_from_slice(args);
|
||||
|
||||
let process = Command::new("cargo")
|
||||
.args(&["run", "--package", "dirigate", "--"])
|
||||
.args(&cmd_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn mocker: {}", e))?;
|
||||
|
||||
// Give the HTTP server time to start
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// TODO: Add health check to verify mocker is ready
|
||||
|
||||
Ok(Self {
|
||||
process,
|
||||
mode: MockerMode::Http { port },
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the mocker mode.
|
||||
pub fn mode(&self) -> MockerMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Get the HTTP URL if in HTTP mode.
|
||||
pub fn http_url(&self) -> Option<String> {
|
||||
match self.mode {
|
||||
MockerMode::Http { port } => Some(format!("http://localhost:{}", port)),
|
||||
MockerMode::Stdio => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill the mocker process.
|
||||
pub async fn kill(mut self) -> Result<(), String> {
|
||||
self.process
|
||||
.kill()
|
||||
.map_err(|e| format!("Failed to kill mocker: {}", e))?;
|
||||
|
||||
// Wait for process to exit
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MockerProcess {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort cleanup on drop
|
||||
let _ = self.process.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for mocker test scenarios.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockerConfig {
|
||||
/// Preset configuration name
|
||||
pub preset: Option<String>,
|
||||
/// Custom configuration JSON/TOML
|
||||
pub config: Option<String>,
|
||||
}
|
||||
|
||||
impl MockerConfig {
|
||||
/// Create a default mocker configuration.
|
||||
pub fn default() -> Self {
|
||||
Self {
|
||||
preset: None,
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a mocker configuration with a preset.
|
||||
pub fn with_preset(preset: impl Into<String>) -> Self {
|
||||
Self {
|
||||
preset: Some(preset.into()),
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a mocker configuration with custom config.
|
||||
// Test utility - kept for future mocker configuration test development
|
||||
#[allow(dead_code)]
|
||||
pub fn with_config(config: impl Into<String>) -> Self {
|
||||
Self {
|
||||
preset: None,
|
||||
config: Some(config.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to command-line arguments for the mocker.
|
||||
pub fn to_args(&self) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
|
||||
if let Some(preset) = &self.preset {
|
||||
args.push("--preset".to_string());
|
||||
args.push(preset.clone());
|
||||
}
|
||||
|
||||
if let Some(config) = &self.config {
|
||||
args.push("--config".to_string());
|
||||
args.push(config.clone());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mocker_config_default() {
|
||||
let config = MockerConfig::default();
|
||||
assert!(config.preset.is_none());
|
||||
assert!(config.config.is_none());
|
||||
assert!(config.to_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mocker_config_with_preset() {
|
||||
let config = MockerConfig::with_preset("basic");
|
||||
assert_eq!(config.preset, Some("basic".to_string()));
|
||||
assert_eq!(config.to_args(), vec!["--preset", "basic"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! ACP integration test utilities.
|
||||
//!
|
||||
//! This module provides utilities for testing with the conductor.
|
||||
|
||||
pub mod mocker_utils;
|
||||
pub mod golden_transcripts;
|
||||
|
||||
pub use mocker_utils::*;
|
||||
pub use golden_transcripts::*;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! ACP mocker integration tests.
|
||||
//!
|
||||
//! These tests use the dirigate to validate ACP protocol flows.
|
||||
//! Most tests are marked as #[ignore] by default since they require spawning processes.
|
||||
//!
|
||||
//! Run with: cargo test --package dirigent_core --test acp_mocker_test -- --ignored
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
mod acp_integration;
|
||||
|
||||
use acp_integration::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mocker_utilities_exist() {
|
||||
// Just verify the utilities compile and work
|
||||
let config = MockerConfig::default();
|
||||
assert!(config.to_args().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_golden_transcripts_available() {
|
||||
// Verify golden transcripts are available
|
||||
assert!(load_golden_transcript("initialize").is_some());
|
||||
assert!(load_golden_transcript("new_session").is_some());
|
||||
assert!(load_golden_transcript("prompt").is_some());
|
||||
assert!(load_golden_transcript("tool_call_read").is_some());
|
||||
assert!(load_golden_transcript("cancel").is_some());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mocker Spawning Tests (Ignored by default)
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_spawn_stdio_mocker() {
|
||||
let mocker = MockerProcess::spawn_stdio().await;
|
||||
assert!(mocker.is_ok(), "Failed to spawn stdio mocker: {:?}", mocker.err());
|
||||
|
||||
if let Ok(mocker) = mocker {
|
||||
assert!(matches!(mocker.mode(), MockerMode::Stdio));
|
||||
assert_eq!(mocker.http_url(), None);
|
||||
mocker.kill().await.expect("Failed to kill mocker");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_spawn_http_mocker() {
|
||||
let port = 18888; // Use non-standard port for testing
|
||||
let mocker = MockerProcess::spawn_http(port).await;
|
||||
assert!(mocker.is_ok(), "Failed to spawn HTTP mocker: {:?}", mocker.err());
|
||||
|
||||
if let Ok(mocker) = mocker {
|
||||
assert!(matches!(mocker.mode(), MockerMode::Http { .. }));
|
||||
assert_eq!(
|
||||
mocker.http_url(),
|
||||
Some(format!("http://localhost:{}", port))
|
||||
);
|
||||
mocker.kill().await.expect("Failed to kill mocker");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_spawn_mocker_with_config() {
|
||||
let config = MockerConfig::with_preset("basic");
|
||||
let args_vec = config.to_args();
|
||||
let args: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let mocker = MockerProcess::spawn_stdio_with_args(&args).await;
|
||||
assert!(mocker.is_ok(), "Failed to spawn mocker with config: {:?}", mocker.err());
|
||||
|
||||
if let Ok(mocker) = mocker {
|
||||
mocker.kill().await.expect("Failed to kill mocker");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Documentation for Future Tests
|
||||
// ============================================================================
|
||||
|
||||
// TODO: TEST-01 - Protocol validation test suite (stdio mode)
|
||||
// - initialize → response with capabilities
|
||||
// - session/new → sessionId
|
||||
// - session/prompt → streaming updates
|
||||
// - Validate all protocol flows against mocker
|
||||
|
||||
// TODO: TEST-02 - Protocol validation test suite (HTTP mode)
|
||||
// - Same tests as TEST-01 but using HTTP transport
|
||||
// - SSE stream validation
|
||||
|
||||
// TODO: TEST-03 - Session update rendering tests
|
||||
// - Parse and validate all session update types
|
||||
// - Verify ToolKind, ToolCallLocation, diff content
|
||||
|
||||
// TODO: TEST-04 - Permission prompt flow tests
|
||||
// - Test all permission outcomes (allow_once, allow_always, reject_once, reject_always, cancel)
|
||||
// - Decision cache persistence and TTL
|
||||
@@ -0,0 +1,737 @@
|
||||
//! TEST-001: Comprehensive Stdio Integration Tests
|
||||
//!
|
||||
//! This file contains comprehensive integration tests for the ACP client over stdio transport.
|
||||
//! It tests the full lifecycle of ACP communication using the dirigate in stdio mode.
|
||||
//!
|
||||
//! Test Coverage:
|
||||
//! - T001: Full lifecycle (initialize, new session, prompt, cancel, load)
|
||||
//! - T002: Initialize handshake
|
||||
//! - T003: Session creation
|
||||
//! - T004: Message sending with streaming
|
||||
//! - T005: Session cancellation
|
||||
//! - T006: Session loading
|
||||
//! - T007: Multiple concurrent sessions
|
||||
//! - T008: Graceful shutdown
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
use dirigent_core::connectors::acp::{AcpConfig, AcpConnector};
|
||||
use dirigent_core::connectors::{Connector, ConnectorCommand};
|
||||
use dirigent_protocol::Event;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Helper to create a test fixture path
|
||||
fn test_fixture_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join("acp_test.yaml")
|
||||
}
|
||||
|
||||
/// Helper to get the mocker binary path
|
||||
fn mocker_binary_path() -> String {
|
||||
// First try the compiled binary in target directory
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
|
||||
|
||||
// Try debug build first
|
||||
let debug_path = project_root
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join("dirigate.exe");
|
||||
|
||||
if debug_path.exists() {
|
||||
return debug_path.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
// Try release build
|
||||
let release_path = project_root
|
||||
.join("target")
|
||||
.join("release")
|
||||
.join("dirigate.exe");
|
||||
|
||||
if release_path.exists() {
|
||||
return release_path.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
// Fallback to cargo run
|
||||
"cargo".to_string()
|
||||
}
|
||||
|
||||
/// Helper to create mocker args
|
||||
fn mocker_args(fixture_path: PathBuf) -> Vec<String> {
|
||||
let mocker_bin = mocker_binary_path();
|
||||
|
||||
if mocker_bin == "cargo" {
|
||||
vec![
|
||||
"run".to_string(),
|
||||
"--package".to_string(),
|
||||
"dirigate".to_string(),
|
||||
"--".to_string(),
|
||||
"serve".to_string(),
|
||||
"--stdio".to_string(),
|
||||
"--fixtures".to_string(),
|
||||
fixture_path.to_string_lossy().to_string(),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"serve".to_string(),
|
||||
"--stdio".to_string(),
|
||||
"--fixtures".to_string(),
|
||||
fixture_path.to_string_lossy().to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create a test ACP connector with stdio transport
|
||||
fn create_stdio_connector() -> AcpConnector {
|
||||
let mocker_bin = mocker_binary_path();
|
||||
let fixture_path = test_fixture_path();
|
||||
let args = mocker_args(fixture_path);
|
||||
|
||||
let config = AcpConfig::stdio(mocker_bin, args)
|
||||
.with_retry_max_attempts(3)
|
||||
.with_retry_initial_delay(Duration::from_millis(500));
|
||||
|
||||
AcpConnector::new(
|
||||
"stdio-test-connector".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Stdio Test Connector".to_string(),
|
||||
config,
|
||||
dirigent_core::sharing::bus::SharingBus::new(),
|
||||
)
|
||||
.expect("Failed to create connector")
|
||||
}
|
||||
|
||||
/// Helper to wait for event with timeout
|
||||
async fn wait_for_event<F>(
|
||||
events_rx: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
predicate: F,
|
||||
timeout_duration: Duration,
|
||||
) -> anyhow::Result<Event>
|
||||
where
|
||||
F: Fn(&Event) -> bool,
|
||||
{
|
||||
let result = timeout(timeout_duration, async {
|
||||
loop {
|
||||
match events_rx.recv().await {
|
||||
Ok(event) => {
|
||||
if predicate(&event) {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Event receive error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(event)) => Ok(event),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(anyhow::anyhow!("Timeout waiting for event")),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T001: Full Lifecycle Test
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t001_full_lifecycle_stdio() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
// Create connector
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector task
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
let connected_event = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(connected_event, Event::Connected));
|
||||
tracing::info!("✓ Connected to mocker");
|
||||
|
||||
// Create a new session
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_created = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_id = match session_created {
|
||||
Event::SessionCreated { session, .. } => {
|
||||
tracing::info!("✓ Session created: {}", session.id);
|
||||
session.id
|
||||
}
|
||||
_ => anyhow::bail!("Expected SessionCreated event"),
|
||||
};
|
||||
|
||||
// Send a message
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Hello, test!".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for message started
|
||||
let message_started = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _message_id = match message_started {
|
||||
Event::MessageStarted { message, .. } => {
|
||||
tracing::info!("✓ Message started: {}", message.id);
|
||||
message.id
|
||||
}
|
||||
_ => anyhow::bail!("Expected MessageStarted event"),
|
||||
};
|
||||
|
||||
// Wait for message content (streaming chunks)
|
||||
let mut received_content = false;
|
||||
for _ in 0..10 {
|
||||
if let Ok(event) = timeout(Duration::from_secs(2), events.recv()).await {
|
||||
match event? {
|
||||
Event::SessionUpdate {
|
||||
connector_id: _,
|
||||
session_id: _,
|
||||
update,
|
||||
} => {
|
||||
if let dirigent_protocol::SessionUpdate::AgentMessageChunk { .. } = update {
|
||||
received_content = true;
|
||||
tracing::info!("✓ Received message content");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Event::MessageCompleted { message, .. } => {
|
||||
tracing::info!("✓ Message completed: {}", message.id);
|
||||
received_content = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(received_content, "Should receive message content");
|
||||
|
||||
// Test cancellation (send another message then cancel immediately)
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "This should be cancelled".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Cancel immediately
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CancelGeneration {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!("✓ Sent cancellation request");
|
||||
|
||||
// Stop the connector
|
||||
connector.stop();
|
||||
|
||||
// Wait for task to complete
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
tracing::info!("✓ Full lifecycle completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T002: Initialize Handshake
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t002_initialize_handshake() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connected event
|
||||
let connected = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(connected, Event::Connected));
|
||||
tracing::info!("✓ Initialize handshake completed");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T003: Session Creation
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t003_session_creation() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create session
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for session created event
|
||||
let session_created = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match session_created {
|
||||
Event::SessionCreated { session, .. } => {
|
||||
assert_eq!(session.title, "Session Creation Test");
|
||||
tracing::info!("✓ Session created with correct title");
|
||||
}
|
||||
_ => anyhow::bail!("Expected SessionCreated event"),
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T004: Message Sending with Streaming
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t004_message_streaming() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection and create session
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send message
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Tell me a story".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for message started
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Collect streaming chunks
|
||||
let mut chunk_count = 0;
|
||||
let mut completed = false;
|
||||
|
||||
for _ in 0..20 {
|
||||
if let Ok(result) = timeout(Duration::from_secs(2), events.recv()).await {
|
||||
match result? {
|
||||
Event::SessionUpdate { update, .. } => {
|
||||
if let dirigent_protocol::SessionUpdate::AgentMessageChunk { .. } = update {
|
||||
chunk_count += 1;
|
||||
tracing::info!("Received chunk {}", chunk_count);
|
||||
}
|
||||
}
|
||||
Event::MessageCompleted { .. } => {
|
||||
completed = true;
|
||||
tracing::info!("✓ Message completed after {} chunks", chunk_count);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(chunk_count > 0, "Should receive at least one chunk");
|
||||
assert!(completed, "Message should complete");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T005: Session Cancellation
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t005_session_cancellation() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Setup: connect and create session
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => session.id,
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
// Send message
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Long response please".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for message to start
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::MessageStarted { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Wait for at least one chunk
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionUpdate { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Now cancel
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CancelGeneration {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!("✓ Cancellation sent successfully");
|
||||
|
||||
// The message should either complete or be cancelled
|
||||
// Either way, we verify the cancel command was accepted
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T006: Session Loading
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t006_session_loading() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Try to load a session from the fixture
|
||||
// The fixture should have a pre-defined session we can load
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::LoadSession {
|
||||
session_id: "test-session-1".to_string(),
|
||||
cwd: ".".to_string(),
|
||||
mcp_servers: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wait for session loaded event
|
||||
let session_loaded = wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match session_loaded {
|
||||
Event::SessionCreated { session, .. } => {
|
||||
assert_eq!(session.id, "test-session-1");
|
||||
tracing::info!("✓ Session loaded successfully");
|
||||
}
|
||||
_ => anyhow::bail!("Expected SessionCreated event"),
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T007: Multiple Concurrent Sessions
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t007_multiple_concurrent_sessions() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cmd_tx = connector.command_tx();
|
||||
let mut session_ids = Vec::new();
|
||||
|
||||
// Create multiple sessions
|
||||
for i in 0..3 {
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::CreateSession {
|
||||
cwd: None,
|
||||
project_id: None,
|
||||
ownership: dirigent_protocol::SessionOwnership::internal(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let session_id = match wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::SessionCreated { .. }),
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Event::SessionCreated { session, .. } => {
|
||||
tracing::info!("✓ Created session {}: {}", i + 1, session.id);
|
||||
session.id
|
||||
}
|
||||
_ => anyhow::bail!("Expected SessionCreated"),
|
||||
};
|
||||
|
||||
session_ids.push(session_id);
|
||||
}
|
||||
|
||||
assert_eq!(session_ids.len(), 3, "Should create 3 sessions");
|
||||
|
||||
// Send messages to all sessions concurrently
|
||||
for (i, session_id) in session_ids.iter().enumerate() {
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: format!("Message to session {}", i + 1),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Wait for all messages to start
|
||||
let mut started_count = 0;
|
||||
for _ in 0..30 {
|
||||
if let Ok(result) = timeout(Duration::from_secs(1), events.recv()).await {
|
||||
if let Event::MessageStarted { .. } = result? {
|
||||
started_count += 1;
|
||||
if started_count == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(started_count, 3, "All messages should start");
|
||||
tracing::info!("✓ All messages started successfully");
|
||||
|
||||
// Cleanup
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T008: Graceful Shutdown
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires dirigate binary to be built"]
|
||||
async fn test_t008_graceful_shutdown() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter("debug")
|
||||
.with_test_writer()
|
||||
.try_init()
|
||||
.ok();
|
||||
|
||||
let connector = create_stdio_connector();
|
||||
let mut events = connector.subscribe();
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
wait_for_event(
|
||||
&mut events,
|
||||
|e| matches!(e, Event::Connected),
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Connected, now shutting down gracefully");
|
||||
|
||||
// Stop the connector
|
||||
connector.stop();
|
||||
|
||||
// Task should complete without hanging
|
||||
let result = timeout(Duration::from_secs(5), task_handle).await;
|
||||
|
||||
assert!(result.is_ok(), "Task should complete within timeout");
|
||||
tracing::info!("✓ Graceful shutdown completed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Tests for Event Adapter Integration
|
||||
//!
|
||||
//! T071: Event Adapter Integration
|
||||
//! - SSE events translated correctly by OpenCodeAdapter
|
||||
//! - Events forwarded to broadcast channel
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
use dirigent_protocol::adapters::OpenCodeAdapter;
|
||||
use dirigent_protocol::Event;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
// ============================================================================
|
||||
// T071: Event Adapter Integration
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_t071_adapter_can_be_created() {
|
||||
let _adapter = OpenCodeAdapter::new();
|
||||
// If this compiles and runs, the adapter can be instantiated
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_t071_adapter_translates_session_created() {
|
||||
use opencode_client::types as oc;
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
let oc_session = oc::Session {
|
||||
id: "session-123".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
directory: "/test".to_string(),
|
||||
parent_id: None,
|
||||
summary: None,
|
||||
share: None,
|
||||
title: "Test Session".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
time: oc::SessionTime {
|
||||
created: 1234567890,
|
||||
updated: 1234567890,
|
||||
compacting: None,
|
||||
},
|
||||
revert: None,
|
||||
};
|
||||
|
||||
let oc_event = oc::Event::SessionCreated {
|
||||
properties: oc::SessionEventInfo {
|
||||
info: oc_session.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok(), "Translation should succeed");
|
||||
|
||||
if let Ok(Event::SessionCreated { session, .. }) = result {
|
||||
assert_eq!(session.id, "session-123");
|
||||
assert_eq!(session.title, "Test Session");
|
||||
} else {
|
||||
panic!("Expected SessionCreated event, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_t071_adapter_translates_message_updated() {
|
||||
use opencode_client::types as oc;
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg-123".to_string(),
|
||||
session_id: "session-456".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1234567890,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: None,
|
||||
provider_id: None,
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: oc::TokenUsage::default(),
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok(), "Translation should succeed");
|
||||
|
||||
// The adapter might return MessageStarted or MessageCompleted depending on status
|
||||
match result {
|
||||
Ok(Event::MessageStarted { message, .. }) => {
|
||||
assert_eq!(message.id, "msg-123");
|
||||
assert_eq!(message.session_id, "session-456");
|
||||
}
|
||||
Ok(Event::MessageCompleted { message, .. }) => {
|
||||
assert_eq!(message.id, "msg-123");
|
||||
assert_eq!(message.session_id, "session-456");
|
||||
}
|
||||
other => {
|
||||
panic!(
|
||||
"Expected MessageStarted or MessageCompleted, got {:?}",
|
||||
other
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_t071_adapter_handles_duplicate_events() {
|
||||
use opencode_client::types as oc;
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg-same".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1234567890,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: None,
|
||||
provider_id: None,
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: oc::TokenUsage::default(),
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
// First translation should succeed
|
||||
let result1 = adapter.translate_event(oc_event.clone());
|
||||
assert!(result1.is_ok(), "First translation should succeed");
|
||||
|
||||
// Second translation of the same event should return Duplicate error
|
||||
let result2 = adapter.translate_event(oc_event);
|
||||
assert!(
|
||||
result2.is_err(),
|
||||
"Second translation should detect duplicate"
|
||||
);
|
||||
|
||||
if let Err(dirigent_protocol::adapters::opencode::TranslationError::Duplicate) = result2 {
|
||||
// Expected
|
||||
} else {
|
||||
panic!("Expected Duplicate error, got {:?}", result2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t071_events_forwarded_to_broadcast_channel() {
|
||||
// Test that the broadcast channel mechanism works
|
||||
// We create our own channels to test the pattern used by connectors
|
||||
let (events_tx, mut events_rx) = tokio::sync::broadcast::channel(1000);
|
||||
|
||||
// Send a test event
|
||||
events_tx.send(dirigent_protocol::Event::Connected).ok();
|
||||
|
||||
// Verify we can receive it
|
||||
let event_received = timeout(Duration::from_millis(100), events_rx.recv()).await;
|
||||
|
||||
assert!(
|
||||
event_received.is_ok(),
|
||||
"Should receive events via broadcast channel"
|
||||
);
|
||||
assert!(matches!(
|
||||
event_received.unwrap().unwrap(),
|
||||
dirigent_protocol::Event::Connected
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t071_multiple_subscribers_receive_same_events() {
|
||||
// Test that multiple subscribers all receive broadcast events
|
||||
let (events_tx, _) = tokio::sync::broadcast::channel(1000);
|
||||
|
||||
// Create multiple subscriptions
|
||||
let mut events1 = events_tx.subscribe();
|
||||
let mut events2 = events_tx.subscribe();
|
||||
let mut events3 = events_tx.subscribe();
|
||||
|
||||
// Send an event
|
||||
events_tx.send(dirigent_protocol::Event::Connected).ok();
|
||||
|
||||
// All subscribers should receive events
|
||||
let timeout_duration = Duration::from_millis(100);
|
||||
|
||||
let result1 = timeout(timeout_duration, events1.recv()).await;
|
||||
let result2 = timeout(timeout_duration, events2.recv()).await;
|
||||
let result3 = timeout(timeout_duration, events3.recv()).await;
|
||||
|
||||
// All three should receive events
|
||||
assert!(result1.is_ok(), "Subscriber 1 should receive events");
|
||||
assert!(result2.is_ok(), "Subscriber 2 should receive events");
|
||||
assert!(result3.is_ok(), "Subscriber 3 should receive events");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t071_events_contain_correct_data() {
|
||||
// Test that events can be sent and received with correct data
|
||||
let (events_tx, mut events_rx) = tokio::sync::broadcast::channel(1000);
|
||||
|
||||
// Send various event types
|
||||
events_tx.send(Event::Connected).ok();
|
||||
events_tx.send(Event::Disconnected).ok();
|
||||
events_tx
|
||||
.send(Event::Error {
|
||||
message: "Test error".to_string(),
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Collect events
|
||||
let mut received_events = Vec::new();
|
||||
while let Ok(result) = timeout(Duration::from_millis(10), events_rx.recv()).await {
|
||||
if let Ok(event) = result {
|
||||
received_events.push(event);
|
||||
}
|
||||
if received_events.len() >= 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// We should have received all events
|
||||
assert_eq!(received_events.len(), 3, "Should receive all events");
|
||||
|
||||
// Check that events are valid Event enum variants
|
||||
for event in &received_events {
|
||||
match event {
|
||||
Event::Connected => {}
|
||||
Event::Disconnected => {}
|
||||
Event::Error { message } => {
|
||||
assert!(!message.is_empty(), "Error messages should not be empty");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_t071_adapter_translates_message_part() {
|
||||
use opencode_client::types as oc;
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// First, create a message so the part can be associated
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg-123".to_string(),
|
||||
session_id: "session-456".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1234567890,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: None,
|
||||
provider_id: None,
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: oc::TokenUsage::default(),
|
||||
});
|
||||
|
||||
let msg_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
adapter
|
||||
.translate_event(msg_event)
|
||||
.expect("Message should translate");
|
||||
|
||||
// Now translate a message part
|
||||
let oc_part = oc::Part::Text(oc::TextPart {
|
||||
id: "part-123".to_string(),
|
||||
session_id: "session-456".to_string(),
|
||||
message_id: "msg-123".to_string(),
|
||||
text: "Hello, world!".to_string(),
|
||||
synthetic: None,
|
||||
time: Some(oc::PartTime {
|
||||
start: 1234567890,
|
||||
end: Some(1234567900),
|
||||
}),
|
||||
});
|
||||
|
||||
let part_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: oc_part.clone(),
|
||||
delta: Some("Hello, world!".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(part_event);
|
||||
assert!(result.is_ok(), "Part translation should succeed");
|
||||
|
||||
if let Ok(Event::SessionUpdate { connector_id: _, session_id, update }) = result {
|
||||
assert_eq!(session_id, "ses-456");
|
||||
match update {
|
||||
dirigent_protocol::SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(message_id, "msg-123");
|
||||
match content {
|
||||
dirigent_protocol::ContentBlock::Text { text } => {
|
||||
assert_eq!(text, "Hello, world!");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
} else {
|
||||
panic!("Expected SessionUpdate event, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t071_broadcast_channel_capacity() {
|
||||
// Test that the broadcast channel has sufficient capacity
|
||||
let (events_tx, mut events_rx) = tokio::sync::broadcast::channel(1000);
|
||||
|
||||
// Send many events
|
||||
for i in 0..100 {
|
||||
events_tx
|
||||
.send(Event::Error {
|
||||
message: format!("Event {}", i),
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Receive them all
|
||||
let mut count = 0;
|
||||
while let Ok(result) = timeout(Duration::from_millis(10), events_rx.recv()).await {
|
||||
if result.is_ok() {
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if count >= 100 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// We should have received all events without lagging
|
||||
assert_eq!(count, 100, "Should receive all events without lag");
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Integration tests for file embedding functionality.
|
||||
//!
|
||||
//! Tests the complete embedding pipeline from file paths to ContentBlocks.
|
||||
|
||||
use dirigent_core::acp::content_blocks::build_content_blocks_from_files;
|
||||
use dirigent_core::acp::protocol::prompt::{ContentBlock, EmbeddedResource};
|
||||
use dirigent_core::acp::{AgentCapabilities, PromptCapabilities};
|
||||
use dirigent_tools::config::{EmbeddingConfig, SandboxConfig};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Helper to create test agent capabilities.
|
||||
fn create_test_agent_caps(embedded_context: bool) -> AgentCapabilities {
|
||||
AgentCapabilities {
|
||||
load_session: None,
|
||||
prompt_capabilities: Some(PromptCapabilities {
|
||||
image: None,
|
||||
audio: None,
|
||||
embedded_context: Some(embedded_context),
|
||||
}),
|
||||
mcp: None,
|
||||
_meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create test configuration.
|
||||
fn create_test_config(temp_dir: &TempDir) -> (EmbeddingConfig, SandboxConfig) {
|
||||
let embedding_config = EmbeddingConfig {
|
||||
max_embed_bytes: 1000,
|
||||
allow_resource_link: true,
|
||||
redact_patterns: vec![],
|
||||
snippet_strategy: dirigent_tools::config::SnippetStrategy::HeadTail,
|
||||
max_files_per_prompt: 10,
|
||||
};
|
||||
|
||||
let mut sandbox_config = SandboxConfig::default();
|
||||
sandbox_config.allowed_roots = vec![temp_dir.path().to_path_buf()];
|
||||
sandbox_config.normalize_roots();
|
||||
|
||||
(embedding_config, sandbox_config)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_1_small_text_capability_on_embed() {
|
||||
// Scenario 1: Small text file + capability on → embed
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("small.txt");
|
||||
std::fs::write(&file_path, "Hello, world!").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1, "Should have one content block");
|
||||
|
||||
match &blocks[0] {
|
||||
ContentBlock::Resource { resource, .. } => match resource {
|
||||
EmbeddedResource::Text { text, .. } => {
|
||||
assert_eq!(text, "Hello, world!");
|
||||
}
|
||||
_ => panic!("Expected text resource"),
|
||||
},
|
||||
_ => panic!("Expected resource block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_2_large_text_capability_on_link() {
|
||||
// Scenario 2: Large text file + capability on → link or snippet
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("large.txt");
|
||||
let large_content = "x".repeat(2000); // Exceeds 1000 byte limit
|
||||
std::fs::write(&file_path, &large_content).unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1, "Should have one content block");
|
||||
|
||||
match &blocks[0] {
|
||||
ContentBlock::ResourceLink { size, .. } => {
|
||||
assert_eq!(*size, Some(2000));
|
||||
}
|
||||
_ => panic!("Expected resource link block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_3_binary_file_link() {
|
||||
// Scenario 3: Binary file → link
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("image.png");
|
||||
std::fs::write(&file_path, b"\x89PNG\r\n\x1a\n").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1, "Should have one content block");
|
||||
|
||||
match &blocks[0] {
|
||||
ContentBlock::ResourceLink { mime_type, .. } => {
|
||||
assert_eq!(mime_type, &Some("image/png".to_string()));
|
||||
}
|
||||
_ => panic!("Expected resource link block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_4_capability_off_all_links() {
|
||||
// Scenario 4: Capability off → all links
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "Test content").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(false); // Capability OFF
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1, "Should have one content block");
|
||||
|
||||
match &blocks[0] {
|
||||
ContentBlock::ResourceLink { .. } => {
|
||||
// Correct - should be link when capability is off
|
||||
}
|
||||
_ => panic!("Expected resource link block when capability is off"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_5_exceed_total_cap_link_remaining() {
|
||||
// Scenario 5: Exceed total cap → deny or link remaining
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create multiple small files that together exceed the total cap
|
||||
let file1 = temp_dir.path().join("file1.txt");
|
||||
let file2 = temp_dir.path().join("file2.txt");
|
||||
let file3 = temp_dir.path().join("file3.txt");
|
||||
|
||||
let content = "x".repeat(800); // Each file is 800 bytes
|
||||
std::fs::write(&file1, &content).unwrap();
|
||||
std::fs::write(&file2, &content).unwrap();
|
||||
std::fs::write(&file3, &content).unwrap();
|
||||
// Total: 2400 bytes, but max_embed_bytes * max_files_per_prompt = 1000 * 10 = 10000
|
||||
// So they should all embed in this test
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file1, file2, file3],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// All should be embedded or linked (not denied)
|
||||
assert!(blocks.len() >= 2, "Should have at least 2 content blocks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_6_exceed_file_count_deny() {
|
||||
// Scenario 6: Exceed file count → deny
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut embedding_config = EmbeddingConfig::default();
|
||||
embedding_config.max_files_per_prompt = 2; // Limit to 2 files
|
||||
|
||||
let mut sandbox_config = SandboxConfig::default();
|
||||
sandbox_config.allowed_roots = vec![temp_dir.path().to_path_buf()];
|
||||
sandbox_config.normalize_roots();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
|
||||
// Create 3 files
|
||||
let file1 = temp_dir.path().join("file1.txt");
|
||||
let file2 = temp_dir.path().join("file2.txt");
|
||||
let file3 = temp_dir.path().join("file3.txt");
|
||||
|
||||
std::fs::write(&file1, "File 1").unwrap();
|
||||
std::fs::write(&file2, "File 2").unwrap();
|
||||
std::fs::write(&file3, "File 3").unwrap();
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file1, file2, file3],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should have at most 2 blocks (third file denied)
|
||||
assert!(blocks.len() <= 2, "Should have at most 2 content blocks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_7_redaction_patterns_applied() {
|
||||
// Scenario 7: Redaction patterns applied → verify content redacted
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("secrets.txt");
|
||||
std::fs::write(&file_path, "api_key: sk-1234567890abcdef").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let mut embedding_config = EmbeddingConfig::default();
|
||||
embedding_config.redact_patterns = vec![
|
||||
r"(?i)(api[_-]?key):\s*([a-zA-Z0-9_\-\.]+)".to_string(),
|
||||
];
|
||||
|
||||
let mut sandbox_config = SandboxConfig::default();
|
||||
sandbox_config.allowed_roots = vec![temp_dir.path().to_path_buf()];
|
||||
sandbox_config.normalize_roots();
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1, "Should have one content block");
|
||||
|
||||
match &blocks[0] {
|
||||
ContentBlock::Resource { resource, .. } => match resource {
|
||||
EmbeddedResource::Text { text, .. } => {
|
||||
// Verify that the API key is redacted
|
||||
assert!(
|
||||
!text.contains("sk-1234567890abcdef"),
|
||||
"Secret should be redacted"
|
||||
);
|
||||
assert!(text.contains("REDACTED"), "Should contain redaction marker");
|
||||
}
|
||||
_ => panic!("Expected text resource"),
|
||||
},
|
||||
_ => panic!("Expected resource block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_8_sandbox_violation_deny() {
|
||||
// Scenario 8: Sandbox violation → deny with clear error
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let outside_dir = TempDir::new().unwrap();
|
||||
let file_path = outside_dir.path().join("outside.txt");
|
||||
std::fs::write(&file_path, "Outside sandbox").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let result = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
);
|
||||
|
||||
// Should fail with sandbox violation
|
||||
assert!(result.is_err(), "Should fail with sandbox violation");
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
format!("{:?}", err).contains("SandboxViolation"),
|
||||
"Error should be SandboxViolation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scenario_9_mixed_strategies() {
|
||||
// Scenario 9: Mixed strategies in one prompt → correct blocks
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Small text file (will be embedded)
|
||||
let small_file = temp_dir.path().join("small.txt");
|
||||
std::fs::write(&small_file, "Small content").unwrap();
|
||||
|
||||
// Large text file (will be linked)
|
||||
let large_file = temp_dir.path().join("large.txt");
|
||||
let large_content = "x".repeat(2000);
|
||||
std::fs::write(&large_file, &large_content).unwrap();
|
||||
|
||||
// Binary file (will be linked)
|
||||
let binary_file = temp_dir.path().join("image.png");
|
||||
std::fs::write(&binary_file, b"\x89PNG\r\n\x1a\n").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&[small_file, large_file, binary_file],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 3, "Should have three content blocks");
|
||||
|
||||
// First should be embedded
|
||||
match &blocks[0] {
|
||||
ContentBlock::Resource { .. } => {
|
||||
// Correct - small file is embedded
|
||||
}
|
||||
_ => panic!("Expected first block to be Resource (embedded)"),
|
||||
}
|
||||
|
||||
// Second and third should be links
|
||||
match &blocks[1] {
|
||||
ContentBlock::ResourceLink { .. } => {
|
||||
// Correct - large file is linked
|
||||
}
|
||||
_ => panic!("Expected second block to be ResourceLink"),
|
||||
}
|
||||
|
||||
match &blocks[2] {
|
||||
ContentBlock::ResourceLink { .. } => {
|
||||
// Correct - binary file is linked
|
||||
}
|
||||
_ => panic!("Expected third block to be ResourceLink"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uri_stability() {
|
||||
// Test that URIs are stable across multiple invocations
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let file_path = temp_dir.path().join("stable.txt");
|
||||
std::fs::write(&file_path, "Stable content").unwrap();
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let blocks1 = build_content_blocks_from_files(
|
||||
&[file_path.clone()],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let blocks2 = build_content_blocks_from_files(
|
||||
&[file_path],
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Extract URIs and compare
|
||||
match (&blocks1[0], &blocks2[0]) {
|
||||
(
|
||||
ContentBlock::Resource {
|
||||
resource: EmbeddedResource::Text { uri: uri1, .. },
|
||||
..
|
||||
},
|
||||
ContentBlock::Resource {
|
||||
resource: EmbeddedResource::Text { uri: uri2, .. },
|
||||
..
|
||||
},
|
||||
) => {
|
||||
assert_eq!(uri1, uri2, "URIs should be stable");
|
||||
}
|
||||
_ => panic!("Expected resource blocks with text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_performance_many_files() {
|
||||
// Test that building content blocks for many files is reasonably fast
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create 10 files
|
||||
let mut files = Vec::new();
|
||||
for i in 0..10 {
|
||||
let file_path = temp_dir.path().join(format!("file{}.txt", i));
|
||||
std::fs::write(&file_path, format!("Content {}", i)).unwrap();
|
||||
files.push(file_path);
|
||||
}
|
||||
|
||||
let agent_caps = create_test_agent_caps(true);
|
||||
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let blocks = build_content_blocks_from_files(
|
||||
&files,
|
||||
&agent_caps,
|
||||
&embedding_config,
|
||||
&sandbox_config,
|
||||
)
|
||||
.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert_eq!(blocks.len(), 10, "Should have 10 content blocks");
|
||||
assert!(
|
||||
elapsed.as_millis() < 500,
|
||||
"Should complete in less than 500ms, took {}ms",
|
||||
elapsed.as_millis()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Test fixture for ACP integration tests
|
||||
version: "0.1"
|
||||
|
||||
sessions:
|
||||
# Pre-defined session for loading tests
|
||||
- id: "test-session-1"
|
||||
title: "Preloaded Test Session"
|
||||
created_at: "2025-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: "test-session-1"
|
||||
role: user
|
||||
content: "This is a preloaded message"
|
||||
created_at: "2025-01-01T00:00:01Z"
|
||||
- id: "msg-2"
|
||||
session_id: "test-session-1"
|
||||
role: assistant
|
||||
content: "This is a preloaded response"
|
||||
created_at: "2025-01-01T00:00:02Z"
|
||||
parent_id: "msg-1"
|
||||
|
||||
responders:
|
||||
keyword_map:
|
||||
hello: "Hello! How can I help you today?"
|
||||
test: "This is a test response"
|
||||
story: "Once upon a time, in a digital realm far away, there lived a brave ACP connector who ventured forth to test the limits of communication protocols."
|
||||
default_strategy: echo
|
||||
|
||||
streaming:
|
||||
enabled: true
|
||||
tokens_per_chunk: 3
|
||||
chunk_interval_ms: 50
|
||||
jitter_ms: 10
|
||||
@@ -0,0 +1,634 @@
|
||||
#![cfg(feature = "server")]
|
||||
//! Full integration tests for CoreRuntime
|
||||
|
||||
//!
|
||||
|
||||
//! T076: Full Runtime Lifecycle
|
||||
|
||||
//! T077: Multiple Connectors
|
||||
|
||||
use dirigent_core::connectors::{Connector, ConnectorCommand, ConnectorHandle};
|
||||
use dirigent_core::types::{ConnectorKind, ConnectorState};
|
||||
use dirigent_core::{ConnectorConfig, CoreConfig, CoreRuntime};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Helper to create a test runtime
|
||||
fn create_test_runtime() -> CoreRuntime {
|
||||
CoreRuntime::new(CoreConfig::default(), None)
|
||||
}
|
||||
|
||||
/// Helper to create an OpenCode connector config
|
||||
fn create_opencode_config(id: &str, title: &str) -> ConnectorConfig {
|
||||
ConnectorConfig {
|
||||
id: Some(id.to_string()),
|
||||
kind: ConnectorKind::OpenCode,
|
||||
owner: None,
|
||||
title: Some(title.to_string()),
|
||||
working_directory: None,
|
||||
params: json!({
|
||||
"base_url": "http://localhost:12225",
|
||||
"title": title,
|
||||
"initial_session": null
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock connector for testing (simpler than OpenCode)
|
||||
struct MockConnector {
|
||||
id: String,
|
||||
owner: dirigent_core::UserId,
|
||||
title: String,
|
||||
state: Arc<RwLock<ConnectorState>>,
|
||||
cmd_tx: mpsc::Sender<ConnectorCommand>,
|
||||
cmd_rx: Arc<RwLock<Option<mpsc::Receiver<ConnectorCommand>>>>,
|
||||
events_tx: broadcast::Sender<dirigent_protocol::Event>,
|
||||
}
|
||||
|
||||
impl MockConnector {
|
||||
fn new(id: String, owner: dirigent_core::UserId, title: String) -> Self {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel(100);
|
||||
let (events_tx, _) = broadcast::channel(1000);
|
||||
|
||||
Self {
|
||||
id,
|
||||
owner,
|
||||
title,
|
||||
state: Arc::new(RwLock::new(ConnectorState::Initializing)),
|
||||
cmd_tx,
|
||||
cmd_rx: Arc::new(RwLock::new(Some(cmd_rx))),
|
||||
events_tx,
|
||||
}
|
||||
}
|
||||
|
||||
fn events_sender(&self) -> broadcast::Sender<dirigent_protocol::Event> {
|
||||
self.events_tx.clone()
|
||||
}
|
||||
|
||||
async fn start_task(&self) -> tokio::task::JoinHandle<()> {
|
||||
let id = self.id.clone();
|
||||
let state = Arc::clone(&self.state);
|
||||
let events_tx = self.events_tx.clone();
|
||||
let cmd_rx = self
|
||||
.cmd_rx
|
||||
.write()
|
||||
.await
|
||||
.take()
|
||||
.expect("start_task called more than once");
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::run_task(id, state, events_tx, cmd_rx).await;
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_task(
|
||||
_id: String,
|
||||
state: Arc<RwLock<ConnectorState>>,
|
||||
events_tx: broadcast::Sender<dirigent_protocol::Event>,
|
||||
mut cmd_rx: mpsc::Receiver<ConnectorCommand>,
|
||||
) {
|
||||
// Transition to Ready immediately
|
||||
{
|
||||
let mut state_guard = state.write().await;
|
||||
*state_guard = ConnectorState::Ready;
|
||||
}
|
||||
let _ = events_tx.send(dirigent_protocol::Event::Connected);
|
||||
|
||||
// Process commands
|
||||
while let Some(cmd) = cmd_rx.recv().await {
|
||||
match cmd {
|
||||
ConnectorCommand::ListSessions => {
|
||||
let _ = events_tx.send(dirigent_protocol::Event::SessionsListed {
|
||||
connector_id: "test-connector".to_string(),
|
||||
sessions: vec![],
|
||||
});
|
||||
}
|
||||
ConnectorCommand::ListMessages { .. } => {
|
||||
let _ = events_tx
|
||||
.send(dirigent_protocol::Event::MessagesListed { messages: vec![] });
|
||||
}
|
||||
ConnectorCommand::CreateSession { .. } => {
|
||||
// Mock connector doesn't support session creation
|
||||
}
|
||||
ConnectorCommand::LoadSession { .. } => {
|
||||
// Mock connector doesn't support session loading
|
||||
}
|
||||
ConnectorCommand::SendMessage { .. } => {
|
||||
// Just acknowledge
|
||||
}
|
||||
ConnectorCommand::CancelGeneration { .. } => {
|
||||
// Mock connector doesn't support cancellation
|
||||
}
|
||||
ConnectorCommand::Reconnect => {
|
||||
let _ = events_tx.send(dirigent_protocol::Event::Connected);
|
||||
}
|
||||
ConnectorCommand::AgentResponse { .. } => {
|
||||
// Mock connector doesn't handle agent responses
|
||||
}
|
||||
ConnectorCommand::SetSessionMode { .. } => {
|
||||
// Mock connector doesn't support mode switching
|
||||
}
|
||||
ConnectorCommand::SetSessionModel { .. } => {
|
||||
// Mock connector doesn't support model switching
|
||||
}
|
||||
ConnectorCommand::CloseSession { .. } => {
|
||||
// Mock connector doesn't support session close
|
||||
}
|
||||
ConnectorCommand::SetConfigOption { .. } => {
|
||||
// Mock connector doesn't support config options
|
||||
}
|
||||
ConnectorCommand::Shutdown => {
|
||||
let mut state_guard = state.write().await;
|
||||
*state_guard = ConnectorState::Stopped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Connector for MockConnector {
|
||||
fn id(&self) -> &String {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn kind(&self) -> ConnectorKind {
|
||||
ConnectorKind::Mock
|
||||
}
|
||||
|
||||
fn owner(&self) -> &dirigent_core::UserId {
|
||||
&self.owner
|
||||
}
|
||||
|
||||
fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
fn state(&self) -> ConnectorState {
|
||||
match self.state.try_read() {
|
||||
Ok(state_guard) => state_guard.clone(),
|
||||
Err(_) => ConnectorState::Initializing,
|
||||
}
|
||||
}
|
||||
|
||||
fn command_tx(&self) -> mpsc::Sender<ConnectorCommand> {
|
||||
self.cmd_tx.clone()
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<dirigent_protocol::Event> {
|
||||
self.events_tx.subscribe()
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
let cmd_tx = self.cmd_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = cmd_tx.send(ConnectorCommand::Shutdown).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T076: Full Runtime Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t076_full_lifecycle_with_mock_connector() {
|
||||
// Create runtime
|
||||
let _runtime = create_test_runtime();
|
||||
|
||||
// Step 1: Create a mock connector manually (since we can't use Mock kind via API)
|
||||
let mock = MockConnector::new(
|
||||
"mock-1".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Mock Connector 1".to_string(),
|
||||
);
|
||||
|
||||
// Create a handle for it
|
||||
let handle = ConnectorHandle::new(
|
||||
mock.id().clone(),
|
||||
mock.kind(),
|
||||
mock.owner().clone(),
|
||||
mock.title().to_string(),
|
||||
mock.command_tx(),
|
||||
mock.events_sender(),
|
||||
serde_json::json!({}), // Empty config for mock connector
|
||||
None, // working_directory
|
||||
None, // icon_path
|
||||
false, // show_type_overlay
|
||||
);
|
||||
|
||||
// Subscribe to events before starting
|
||||
let mut events = handle.subscribe();
|
||||
|
||||
// Step 2: Start the connector
|
||||
let task_handle = mock.start_task().await;
|
||||
handle.set_task_handle(task_handle).await;
|
||||
|
||||
// Step 3: Wait for it to become Ready
|
||||
let connected = timeout(Duration::from_secs(2), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::Connected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
connected.is_ok() && connected.unwrap(),
|
||||
"Should receive Connected event"
|
||||
);
|
||||
|
||||
// Note: State checking via try_read() may be flaky due to timing.
|
||||
// The important thing is we received the Connected event, which proves
|
||||
// the connector is running and functional.
|
||||
// Skip state assertion as it's not critical for this integration test.
|
||||
|
||||
// Step 4: Send commands and verify events
|
||||
let cmd_tx = handle.command_tx();
|
||||
|
||||
// Send ListSessions
|
||||
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
|
||||
let sessions_listed = timeout(Duration::from_secs(1), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::SessionsListed { .. }) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
sessions_listed.is_ok() && sessions_listed.unwrap(),
|
||||
"Should receive SessionsListed"
|
||||
);
|
||||
|
||||
// Send ListMessages
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::ListMessages {
|
||||
session_id: "test-session".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let messages_listed = timeout(Duration::from_secs(1), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::MessagesListed { .. }) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
messages_listed.is_ok() && messages_listed.unwrap(),
|
||||
"Should receive MessagesListed"
|
||||
);
|
||||
|
||||
// Step 5: Stop the connector
|
||||
handle.stop();
|
||||
|
||||
// Wait for it to stop
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Note: State checking via try_read() is flaky. The important thing is that
|
||||
// we successfully sent the stop command. The connector should be stopping.
|
||||
// Skip strict state assertion.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t076_full_lifecycle_with_opencode_connector() {
|
||||
// This test uses the real OpenCodeConnector but with a fake URL
|
||||
// so it will fail to connect, but we can still verify the lifecycle
|
||||
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Step 1: Create connector
|
||||
let cfg = create_opencode_config("oc-lifecycle", "Lifecycle Test");
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Step 2: Verify it's in the list
|
||||
let list = runtime.list_connectors(None).await;
|
||||
let found = list.iter().find(|c| c.id == connector_id);
|
||||
assert!(found.is_some(), "Connector should be in list");
|
||||
|
||||
// Step 3: Get the connector handle
|
||||
let handle = runtime.get_connector(&connector_id).await.unwrap();
|
||||
assert_eq!(handle.state(), ConnectorState::Initializing);
|
||||
|
||||
// Step 4: Send commands (commands can be queued even if not started)
|
||||
// Note: Since the connector wasn't started, the command channel exists but isn't being processed
|
||||
let result = runtime
|
||||
.send_command(&connector_id, ConnectorCommand::ListSessions)
|
||||
.await;
|
||||
// This should succeed - we can send to the channel
|
||||
if result.is_err() {
|
||||
println!(
|
||||
"Note: Command send failed (channel may be closed): {:?}",
|
||||
result
|
||||
);
|
||||
// Don't fail the test - this is expected if connector wasn't fully initialized
|
||||
}
|
||||
|
||||
// Step 5: Stop the connector
|
||||
let result = runtime.stop_connector(&connector_id).await;
|
||||
// Note: Stop may fail if the connector wasn't properly started, which is ok for this test
|
||||
if result.is_err() {
|
||||
println!(
|
||||
"Note: Stop failed (connector may not have been fully initialized): {:?}",
|
||||
result
|
||||
);
|
||||
} else {
|
||||
// If stop succeeded, verify state changed
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(handle.state(), ConnectorState::Stopped);
|
||||
}
|
||||
|
||||
// Step 6: Remove the connector
|
||||
let result = runtime.remove_connector(&connector_id).await;
|
||||
assert!(result.is_ok(), "Should be able to remove");
|
||||
|
||||
// Step 7: Verify it's gone
|
||||
let list = runtime.list_connectors(None).await;
|
||||
let found = list.iter().find(|c| c.id == connector_id);
|
||||
assert!(found.is_none(), "Connector should be removed from list");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T077: Multiple Connectors
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t077_multiple_connectors_dont_crosstalk() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Create multiple connectors
|
||||
let cfg1 = create_opencode_config("multi-1", "Multi 1");
|
||||
let cfg2 = create_opencode_config("multi-2", "Multi 2");
|
||||
let cfg3 = create_opencode_config("multi-3", "Multi 3");
|
||||
|
||||
let id1 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg1)
|
||||
.await
|
||||
.unwrap();
|
||||
let id2 = runtime
|
||||
.create_connector(uuid::Uuid::from_u128(2), cfg2)
|
||||
.await
|
||||
.unwrap();
|
||||
let id3 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg3)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify all three exist
|
||||
let list = runtime.list_connectors(None).await;
|
||||
assert!(list.iter().any(|c| c.id == id1));
|
||||
assert!(list.iter().any(|c| c.id == id2));
|
||||
assert!(list.iter().any(|c| c.id == id3));
|
||||
|
||||
// Verify they have correct owners
|
||||
let c1 = list.iter().find(|c| c.id == id1).unwrap();
|
||||
let c2 = list.iter().find(|c| c.id == id2).unwrap();
|
||||
let c3 = list.iter().find(|c| c.id == id3).unwrap();
|
||||
|
||||
assert_eq!(c1.owner, uuid::Uuid::nil());
|
||||
assert_eq!(c2.owner, uuid::Uuid::from_u128(2));
|
||||
assert_eq!(c3.owner, uuid::Uuid::nil());
|
||||
|
||||
// Stop one connector (may fail if already stopped, which is ok)
|
||||
let stop_result = runtime.stop_connector(&id2).await;
|
||||
|
||||
if stop_result.is_ok() {
|
||||
// Wait for state to update
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Verify id2 state changed
|
||||
let handle2 = runtime.get_connector(&id2).await.unwrap();
|
||||
let state2 = handle2.state();
|
||||
assert!(
|
||||
matches!(state2, ConnectorState::Stopped),
|
||||
"Expected id2 to be Stopped after stop_connector, got {:?}",
|
||||
state2
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Note: stop_connector failed (connector may not have been started): {:?}",
|
||||
stop_result
|
||||
);
|
||||
}
|
||||
|
||||
// Remove one connector
|
||||
runtime.remove_connector(&id1).await.unwrap();
|
||||
|
||||
// Verify only id1 is removed
|
||||
assert!(runtime.get_connector(&id1).await.is_none());
|
||||
assert!(runtime.get_connector(&id2).await.is_some());
|
||||
assert!(runtime.get_connector(&id3).await.is_some());
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&id2).await.ok();
|
||||
runtime.remove_connector(&id3).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t077_per_connector_broadcasts_work() {
|
||||
// Create mock connectors to test event isolation
|
||||
let mock1 = MockConnector::new(
|
||||
"mock-a".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Mock A".to_string(),
|
||||
);
|
||||
let mock2 = MockConnector::new(
|
||||
"mock-b".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Mock B".to_string(),
|
||||
);
|
||||
|
||||
// Subscribe to events from each
|
||||
let mut events1 = mock1.subscribe();
|
||||
let mut events2 = mock2.subscribe();
|
||||
|
||||
// Start both connectors
|
||||
let _task1 = mock1.start_task().await;
|
||||
let _task2 = mock2.start_task().await;
|
||||
|
||||
// Wait for both to become ready
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Send command to mock1 only
|
||||
mock1
|
||||
.command_tx()
|
||||
.send(ConnectorCommand::ListSessions)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// mock1 should receive SessionsListed
|
||||
let mock1_received = timeout(Duration::from_secs(1), async {
|
||||
while let Ok(event) = events1.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::SessionsListed { .. }) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
mock1_received.is_ok() && mock1_received.unwrap(),
|
||||
"Mock1 should receive event"
|
||||
);
|
||||
|
||||
// mock2 should NOT receive it (only Connected event)
|
||||
let mock2_received = timeout(Duration::from_millis(500), async {
|
||||
while let Ok(event) = events2.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::SessionsListed { .. }) {
|
||||
return true;
|
||||
}
|
||||
// Skip Connected events
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
|
||||
// Should timeout (not receive SessionsListed)
|
||||
assert!(
|
||||
mock2_received.is_err() || !mock2_received.unwrap(),
|
||||
"Mock2 should NOT receive mock1's events"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
mock1.stop();
|
||||
mock2.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t077_concurrent_operations_on_different_connectors() {
|
||||
let runtime = Arc::new(create_test_runtime());
|
||||
|
||||
// Create multiple connectors
|
||||
let cfg1 = create_opencode_config("concurrent-1", "Concurrent 1");
|
||||
let cfg2 = create_opencode_config("concurrent-2", "Concurrent 2");
|
||||
|
||||
let id1 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg1)
|
||||
.await
|
||||
.unwrap();
|
||||
let id2 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg2)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Spawn concurrent operations
|
||||
let runtime1 = Arc::clone(&runtime);
|
||||
let runtime2 = Arc::clone(&runtime);
|
||||
let id1_clone = id1.clone();
|
||||
let id2_clone = id2.clone();
|
||||
|
||||
let task1 = tokio::spawn(async move {
|
||||
// Send multiple commands to connector 1
|
||||
for _ in 0..10 {
|
||||
runtime1
|
||||
.send_command(&id1_clone, ConnectorCommand::ListSessions)
|
||||
.await
|
||||
.ok();
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let task2 = tokio::spawn(async move {
|
||||
// Send multiple commands to connector 2
|
||||
for _ in 0..10 {
|
||||
runtime2
|
||||
.send_command(&id2_clone, ConnectorCommand::ListSessions)
|
||||
.await
|
||||
.ok();
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for both to complete
|
||||
let result1 = task1.await;
|
||||
let result2 = task2.await;
|
||||
|
||||
assert!(result1.is_ok(), "Task 1 should complete successfully");
|
||||
assert!(result2.is_ok(), "Task 2 should complete successfully");
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&id1).await.ok();
|
||||
runtime.remove_connector(&id2).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t077_list_connectors_with_multiple() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Create several connectors for different users
|
||||
for i in 1..=5 {
|
||||
let cfg = create_opencode_config(&format!("user1-conn-{}", i), &format!("User 1 #{}", i));
|
||||
runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for i in 1..=3 {
|
||||
let cfg = create_opencode_config(&format!("user2-conn-{}", i), &format!("User 2 #{}", i));
|
||||
runtime
|
||||
.create_connector(uuid::Uuid::from_u128(2), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// List all
|
||||
let all = runtime.list_connectors(None).await;
|
||||
assert!(all.len() >= 8, "Should have at least 8 connectors");
|
||||
|
||||
// List for user-1
|
||||
let user1_list = runtime.list_connectors(Some(uuid::Uuid::nil())).await;
|
||||
assert_eq!(user1_list.len(), 5, "User 1 should have 5 connectors");
|
||||
|
||||
// List for user-2
|
||||
let user2_list = runtime
|
||||
.list_connectors(Some(uuid::Uuid::from_u128(2)))
|
||||
.await;
|
||||
assert_eq!(user2_list.len(), 3, "User 2 should have 3 connectors");
|
||||
|
||||
// List for user-3 (none)
|
||||
let user3_list = runtime
|
||||
.list_connectors(Some(uuid::Uuid::from_u128(3)))
|
||||
.await;
|
||||
assert_eq!(user3_list.len(), 0, "User 3 should have 0 connectors");
|
||||
|
||||
// Clean up
|
||||
for i in 1..=5 {
|
||||
runtime
|
||||
.remove_connector(&format!("user1-conn-{}", i))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
for i in 1..=3 {
|
||||
runtime
|
||||
.remove_connector(&format!("user2-conn-{}", i))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t077_global_events_subscription() {
|
||||
let _runtime = create_test_runtime();
|
||||
|
||||
// Subscribe to every event on the SharingBus (replaces the retired
|
||||
// `subscribe_global()` API).
|
||||
let bus_rx = _runtime.sharing_bus().subscribe_all().await;
|
||||
|
||||
drop(bus_rx);
|
||||
// If this compiles and runs, bus subscription works
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Integration test: Matrix migration onto StreamRegistry (Phase 4, Task 18).
|
||||
//!
|
||||
//! Scope:
|
||||
//! - `MatrixFactory::kind()` reports `"matrix"`.
|
||||
//! - A fresh `StreamFactoryRegistry` with the factory registered can look it
|
||||
//! up and rejects unknown kinds.
|
||||
//! - Building a Matrix stream from a config with an `archive_wide` scope is
|
||||
//! rejected with `StreamBuildError::Config`.
|
||||
//! - Building a Matrix stream against a not-logged-in service is rejected
|
||||
//! with `StreamBuildError::Transport` (does not panic, does not spin up
|
||||
//! a real Matrix connection).
|
||||
//!
|
||||
//! This does NOT exercise end-to-end Matrix delivery — that requires a
|
||||
//! live homeserver or a stub client, which is outside Task 18's scope.
|
||||
//! The share-side `SessionStream` impl is covered separately by
|
||||
//! `dirigent_matrix` unit tests.
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_auth::{Account, AccountKind, AccountProfile, SecretSource};
|
||||
use dirigent_core::sharing::{
|
||||
MatrixFactory, StreamBuildError, StreamConfig, StreamFactory, StreamFactoryRegistry,
|
||||
};
|
||||
use dirigent_matrix::{MatrixBehaviorConfig, MatrixService};
|
||||
use dirigent_protocol::streaming::StreamScope;
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn sample_matrix_account() -> Account {
|
||||
let mut credentials = HashMap::new();
|
||||
credentials.insert(
|
||||
"password".to_string(),
|
||||
SecretSource::Inline {
|
||||
value: "bot-pass".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
let mut properties = HashMap::new();
|
||||
properties.insert(
|
||||
"homeserver".to_string(),
|
||||
serde_json::json!("https://matrix.example.com"),
|
||||
);
|
||||
properties.insert(
|
||||
"device_id".to_string(),
|
||||
serde_json::json!("DIRIGENT_TEST"),
|
||||
);
|
||||
|
||||
Account {
|
||||
kind: AccountKind::Matrix,
|
||||
config_name: "matrix-test".to_string(),
|
||||
user_id: None,
|
||||
credentials,
|
||||
profile: AccountProfile {
|
||||
username: Some("bot".to_string()),
|
||||
display_name: Some("Test Bot".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
properties,
|
||||
}
|
||||
}
|
||||
|
||||
fn behavior() -> MatrixBehaviorConfig {
|
||||
MatrixBehaviorConfig {
|
||||
account: "matrix-test".to_string(),
|
||||
mode: Default::default(),
|
||||
default_invite: vec![],
|
||||
store_path: "matrix/test/store".to_string(),
|
||||
rooms: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `MatrixService` without calling `login()`. Any code path that
|
||||
/// needs a live Client will surface a clean error (not a panic).
|
||||
fn not_logged_in_service() -> Arc<MatrixService> {
|
||||
let account = sample_matrix_account();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let data_dir: PathBuf = tmp.path().to_path_buf();
|
||||
// Leak the TempDir so the path survives the life of the service for
|
||||
// the duration of the test. The sqlite store is only created when
|
||||
// login() runs — we never call it in these tests.
|
||||
std::mem::forget(tmp);
|
||||
let service = MatrixService::from_account(&account, behavior(), data_dir)
|
||||
.expect("from_account");
|
||||
Arc::new(service)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn matrix_factory_kind_is_matrix() {
|
||||
let service = not_logged_in_service();
|
||||
let f = MatrixFactory::new(service);
|
||||
assert_eq!(f.kind(), "matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_returns_registered_matrix_factory() {
|
||||
let service = not_logged_in_service();
|
||||
let reg = StreamFactoryRegistry::new().register(MatrixFactory::new(service));
|
||||
assert!(reg.get("matrix").is_some(), "matrix factory should be found");
|
||||
assert!(
|
||||
reg.get("langfuse").is_none(),
|
||||
"unregistered kinds must return None"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_rejects_archive_wide_scope_with_config_error() {
|
||||
let service = not_logged_in_service();
|
||||
let factory = MatrixFactory::new(service);
|
||||
|
||||
let params_toml = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
room_id = "!room:example.com"
|
||||
"#;
|
||||
let params: toml::Value = toml::from_str(params_toml).unwrap();
|
||||
|
||||
let cfg = StreamConfig {
|
||||
name: "matrix-wrong-scope".to_string(),
|
||||
kind: "matrix".to_string(),
|
||||
scope: StreamScope::ArchiveWide { acknowledged: false },
|
||||
enabled: true,
|
||||
params,
|
||||
};
|
||||
|
||||
let err = factory.build(&cfg).await.err().expect("build should fail");
|
||||
match err {
|
||||
StreamBuildError::Config(msg) => {
|
||||
assert!(
|
||||
msg.contains("session"),
|
||||
"expected 'session' hint in error, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Config error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_rejects_missing_params_with_config_error() {
|
||||
let service = not_logged_in_service();
|
||||
let factory = MatrixFactory::new(service);
|
||||
|
||||
// Missing room_id — required field.
|
||||
let params_toml = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
"#;
|
||||
let params: toml::Value = toml::from_str(params_toml).unwrap();
|
||||
|
||||
let cfg = StreamConfig {
|
||||
name: "matrix-missing-room".to_string(),
|
||||
kind: "matrix".to_string(),
|
||||
scope: StreamScope::Session {
|
||||
scroll_id: Uuid::now_v7(),
|
||||
},
|
||||
enabled: true,
|
||||
params,
|
||||
};
|
||||
|
||||
let err = factory.build(&cfg).await.err().expect("build should fail");
|
||||
assert!(
|
||||
matches!(err, StreamBuildError::Config(_)),
|
||||
"expected Config error, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_reports_transport_error_when_service_not_logged_in() {
|
||||
let service = not_logged_in_service();
|
||||
let factory = MatrixFactory::new(service);
|
||||
|
||||
let params_toml = r#"
|
||||
connector_id = "opencode-1"
|
||||
session_id = "native-abc"
|
||||
room_id = "!room:example.com"
|
||||
"#;
|
||||
let params: toml::Value = toml::from_str(params_toml).unwrap();
|
||||
|
||||
let cfg = StreamConfig {
|
||||
name: "matrix-not-logged-in".to_string(),
|
||||
kind: "matrix".to_string(),
|
||||
scope: StreamScope::Session {
|
||||
scroll_id: Uuid::now_v7(),
|
||||
},
|
||||
enabled: true,
|
||||
params,
|
||||
};
|
||||
|
||||
let err = factory.build(&cfg).await.err().expect("build should fail");
|
||||
match err {
|
||||
StreamBuildError::Transport(msg) => {
|
||||
assert!(
|
||||
msg.to_lowercase().contains("logged in")
|
||||
|| msg.to_lowercase().contains("matrix service"),
|
||||
"expected transport error to mention login state, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Transport error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
#![cfg(feature = "server")]
|
||||
//! Tests for OpenCodeConnector state transitions and command handling
|
||||
//!
|
||||
//! T069: OpenCodeConnector State Transitions
|
||||
//! T070: OpenCodeConnector Command Handling
|
||||
|
||||
use dirigent_core::connectors::opencode::{OpenCodeConfig, OpenCodeConnector};
|
||||
use dirigent_core::connectors::{Connector, ConnectorCommand};
|
||||
use dirigent_core::sharing::bus::SharingBus;
|
||||
use dirigent_core::types::{ConnectorKind, ConnectorState};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Helper to create a test connector
|
||||
fn create_test_connector(base_url: &str) -> OpenCodeConnector {
|
||||
let config = OpenCodeConfig {
|
||||
base_url: base_url.to_string(),
|
||||
initial_session: None,
|
||||
};
|
||||
|
||||
OpenCodeConnector::new(
|
||||
"test-conn".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Test Connector".to_string(),
|
||||
config,
|
||||
SharingBus::new(),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T069: OpenCodeConnector State Transitions
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t069_initial_state_is_initializing() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
assert_eq!(connector.state(), ConnectorState::Initializing);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t069_connector_metadata() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
|
||||
assert_eq!(connector.id(), "test-conn");
|
||||
assert_eq!(*connector.owner(), uuid::Uuid::nil());
|
||||
assert_eq!(connector.title(), "Test Connector");
|
||||
assert_eq!(connector.kind(), ConnectorKind::OpenCode);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t069_state_transition_connecting() {
|
||||
// This test verifies that when started, the connector transitions to Connecting
|
||||
// Since we don't have a real OpenCode server, it will fail to connect and
|
||||
// enter Error state after retries, but we can verify the initial transition
|
||||
|
||||
let connector = create_test_connector("http://192.0.2.1:12225"); // TEST-NET-1, guaranteed non-routable
|
||||
let _events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Give it a moment to start transitioning
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// At this point, state should have transitioned from Initializing
|
||||
// It will be either Connecting, Ready, or Error (depending on timing)
|
||||
let state = connector.state();
|
||||
assert!(
|
||||
!matches!(state, ConnectorState::Initializing),
|
||||
"Expected state to transition from Initializing, got {:?}",
|
||||
state
|
||||
);
|
||||
|
||||
// Clean up: send shutdown
|
||||
connector.stop();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // This test is flaky due to timing and network conditions - ignore for now
|
||||
async fn test_t069_state_transition_to_error_on_connection_failure() {
|
||||
let connector = create_test_connector("http://192.0.2.1:12225"); // TEST-NET-1, guaranteed non-routable
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection failures (it will retry a few times)
|
||||
let error_received = timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Ok(event) = events.recv().await {
|
||||
if let dirigent_protocol::Event::Error { message: _ } = event {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
error_received.is_ok(),
|
||||
"Should receive error event on connection failure"
|
||||
);
|
||||
|
||||
// Eventually, state should be Error after retries
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let state = connector.state();
|
||||
assert!(
|
||||
matches!(state, ConnectorState::Error(_)),
|
||||
"Expected Error state, got {:?}",
|
||||
state
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t069_state_transition_to_stopped_on_shutdown() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
|
||||
// Start the connector
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Give it a moment to start
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Send shutdown command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::Shutdown).await.unwrap();
|
||||
|
||||
// Wait for task to complete
|
||||
let result = timeout(Duration::from_secs(5), task_handle).await;
|
||||
assert!(result.is_ok(), "Task should complete on shutdown");
|
||||
|
||||
// State should be Stopped
|
||||
let state = connector.state();
|
||||
assert_eq!(state, ConnectorState::Stopped);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T070: OpenCodeConnector Command Handling
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t070_list_sessions_command_sent() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector (it will fail to connect, but we can still send commands)
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Give it a moment to start
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Send ListSessions command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
|
||||
|
||||
// We expect an error event since we can't actually connect
|
||||
// But this verifies the command was processed
|
||||
let error_or_sessions = timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Ok(event) = events.recv().await {
|
||||
match event {
|
||||
dirigent_protocol::Event::Error { .. } => return "error",
|
||||
dirigent_protocol::Event::SessionsListed { .. } => return "sessions",
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
// We expect either an error (can't connect) or sessions (if somehow it worked)
|
||||
assert!(
|
||||
error_or_sessions.is_ok(),
|
||||
"Should receive response to ListSessions"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t070_list_messages_command_sent() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Send ListMessages command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::ListMessages {
|
||||
session_id: "test-session".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// We expect an error event since we can't actually connect
|
||||
let error_or_messages = timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Ok(event) = events.recv().await {
|
||||
match event {
|
||||
dirigent_protocol::Event::Error { .. } => return "error",
|
||||
dirigent_protocol::Event::MessagesListed { .. } => return "messages",
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
error_or_messages.is_ok(),
|
||||
"Should receive response to ListMessages"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t070_send_message_command_sent() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Send SendMessage command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: "test-session".to_string(),
|
||||
text: "Hello, world!".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// We expect an error event since we can't actually connect
|
||||
let error_received = timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::Error { .. }) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
error_received.is_ok(),
|
||||
"Should receive error for SendMessage"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // This test is flaky due to timing and network conditions - ignore for now
|
||||
async fn test_t070_reconnect_command_restarts_sse() {
|
||||
let connector = create_test_connector("http://192.0.2.1:12225"); // TEST-NET-1, guaranteed non-routable
|
||||
let _events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for it to fail and enter Error state
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let state_before = connector.state();
|
||||
assert!(
|
||||
matches!(state_before, ConnectorState::Error(_)),
|
||||
"Should be in Error state before reconnect"
|
||||
);
|
||||
|
||||
// Send Reconnect command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::Reconnect).await.unwrap();
|
||||
|
||||
// Give it a moment to process reconnect
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// State should transition to Connecting (and then Error again)
|
||||
let state_after = connector.state();
|
||||
assert!(
|
||||
matches!(state_after, ConnectorState::Connecting)
|
||||
|| matches!(state_after, ConnectorState::Error(_)),
|
||||
"Should be Connecting or Error after reconnect, got {:?}",
|
||||
state_after
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t070_command_channel_is_cloneable() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
|
||||
// Get multiple command senders
|
||||
let cmd_tx1 = connector.command_tx();
|
||||
let cmd_tx2 = connector.command_tx();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Both senders should work
|
||||
assert!(cmd_tx1.send(ConnectorCommand::ListSessions).await.is_ok());
|
||||
assert!(cmd_tx2.send(ConnectorCommand::ListSessions).await.is_ok());
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t070_multiple_subscriptions() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
|
||||
// Create multiple subscriptions
|
||||
let mut events1 = connector.subscribe();
|
||||
let mut events2 = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Both should receive events
|
||||
let receive1 = timeout(Duration::from_secs(2), events1.recv());
|
||||
let receive2 = timeout(Duration::from_secs(2), events2.recv());
|
||||
|
||||
// At least one should succeed (we'll get error events from failed connection)
|
||||
assert!(receive1.await.is_ok() || receive2.await.is_ok());
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t070_shutdown_command_stops_task() {
|
||||
let connector = create_test_connector("http://localhost:12225");
|
||||
|
||||
// Start the connector
|
||||
let task_handle = connector.start_task().await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Verify task is running by checking state is not Stopped
|
||||
let state_before = connector.state();
|
||||
assert!(!matches!(state_before, ConnectorState::Stopped));
|
||||
|
||||
// Send Shutdown command via command channel
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::Shutdown).await.unwrap();
|
||||
|
||||
// Task should complete
|
||||
let result = timeout(Duration::from_secs(5), task_handle).await;
|
||||
assert!(result.is_ok(), "Task should complete within timeout");
|
||||
assert!(result.unwrap().is_ok(), "Task should not panic");
|
||||
|
||||
// State should be Stopped
|
||||
assert_eq!(connector.state(), ConnectorState::Stopped);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
//! Real OpenCode HTTP API integration tests
|
||||
//!
|
||||
//! T078: OpenCode Real HTTP Tests
|
||||
//!
|
||||
//! These tests are marked with #[ignore] and require a real OpenCode instance
|
||||
//! running. Set the DIRIGENT_TEST_API_URL environment variable to enable them.
|
||||
//!
|
||||
//! Example:
|
||||
//! ```bash
|
||||
//! DIRIGENT_TEST_API_URL=http://localhost:12225 cargo test --package dirigent_core -- --ignored
|
||||
//! ```
|
||||
|
||||
#![cfg(feature = "server")]
|
||||
use dirigent_core::connectors::opencode::{OpenCodeConfig, OpenCodeConnector};
|
||||
|
||||
use dirigent_core::connectors::{Connector, ConnectorCommand};
|
||||
use dirigent_core::sharing::bus::SharingBus;
|
||||
use dirigent_core::types::ConnectorState;
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Get the test API URL from environment or skip the test
|
||||
fn get_test_api_url() -> Option<String> {
|
||||
env::var("DIRIGENT_TEST_API_URL").ok()
|
||||
}
|
||||
|
||||
/// Helper to create a real connector for testing
|
||||
fn create_real_connector(base_url: &str) -> OpenCodeConnector {
|
||||
let config = OpenCodeConfig {
|
||||
base_url: base_url.to_string(),
|
||||
initial_session: None,
|
||||
};
|
||||
|
||||
OpenCodeConnector::new(
|
||||
"real-test".to_string(),
|
||||
uuid::Uuid::nil(),
|
||||
"Real API Test".to_string(),
|
||||
config,
|
||||
SharingBus::new(),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T078: OpenCode Real HTTP Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_t078_real_connection_successful() {
|
||||
let Some(api_url) = get_test_api_url() else {
|
||||
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let connector = create_real_connector(&api_url);
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
let connected = timeout(Duration::from_secs(10), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::Connected) {
|
||||
return true;
|
||||
}
|
||||
if matches!(event, dirigent_protocol::Event::Error { .. }) {
|
||||
eprintln!("Connection error: {:?}", event);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
connected.is_ok() && connected.unwrap(),
|
||||
"Should successfully connect to real OpenCode instance"
|
||||
);
|
||||
|
||||
// Verify state is Ready
|
||||
assert_eq!(connector.state(), ConnectorState::Ready);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
let _ = timeout(Duration::from_secs(5), task_handle).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_t078_real_list_sessions() {
|
||||
let Some(api_url) = get_test_api_url() else {
|
||||
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let connector = create_real_connector(&api_url);
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
let connected = timeout(Duration::from_secs(10), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::Connected) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
connected.is_ok() && connected.unwrap(),
|
||||
"Should connect first"
|
||||
);
|
||||
|
||||
// Send ListSessions command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
|
||||
|
||||
// Wait for SessionsListed event
|
||||
let sessions_received = timeout(Duration::from_secs(5), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let dirigent_protocol::Event::SessionsListed {
|
||||
connector_id: _,
|
||||
sessions,
|
||||
} = event
|
||||
{
|
||||
println!("Received {} sessions", sessions.len());
|
||||
return Some(sessions);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
sessions_received.is_ok(),
|
||||
"Should receive SessionsListed event"
|
||||
);
|
||||
let sessions = sessions_received.unwrap();
|
||||
assert!(sessions.is_some(), "Should have sessions data");
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_t078_real_list_messages() {
|
||||
let Some(api_url) = get_test_api_url() else {
|
||||
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let connector = create_real_connector(&api_url);
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
timeout(Duration::from_secs(10), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::Connected) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// First, get a session ID by listing sessions
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
|
||||
|
||||
let session_id = timeout(Duration::from_secs(5), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let dirigent_protocol::Event::SessionsListed {
|
||||
connector_id: _,
|
||||
sessions,
|
||||
} = event
|
||||
{
|
||||
if let Some(first_session) = sessions.first() {
|
||||
return Some(first_session.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await;
|
||||
|
||||
if session_id.is_err() || session_id.as_ref().unwrap().is_none() {
|
||||
println!("No sessions available to test ListMessages");
|
||||
connector.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
let session_id = session_id.unwrap().unwrap();
|
||||
println!("Testing with session: {}", session_id);
|
||||
|
||||
// Now list messages for that session
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::ListMessages {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let messages_received = timeout(Duration::from_secs(5), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let dirigent_protocol::Event::MessagesListed { messages } = event {
|
||||
println!("Received {} messages", messages.len());
|
||||
return Some(messages);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
messages_received.is_ok(),
|
||||
"Should receive MessagesListed event"
|
||||
);
|
||||
let messages = messages_received.unwrap();
|
||||
assert!(messages.is_some(), "Should have messages data");
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_t078_real_send_message() {
|
||||
let Some(api_url) = get_test_api_url() else {
|
||||
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let connector = create_real_connector(&api_url);
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for connection
|
||||
timeout(Duration::from_secs(10), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::Connected) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Get a session ID
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
|
||||
|
||||
let session_id = timeout(Duration::from_secs(5), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let dirigent_protocol::Event::SessionsListed {
|
||||
connector_id: _,
|
||||
sessions,
|
||||
} = event
|
||||
{
|
||||
if let Some(first_session) = sessions.first() {
|
||||
return Some(first_session.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await;
|
||||
|
||||
if session_id.is_err() || session_id.as_ref().unwrap().is_none() {
|
||||
println!("No sessions available to test SendMessage");
|
||||
connector.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
let session_id = session_id.unwrap().unwrap();
|
||||
println!("Sending message to session: {}", session_id);
|
||||
|
||||
// Send a message
|
||||
cmd_tx
|
||||
.send(ConnectorCommand::SendMessage {
|
||||
session_id: session_id.clone(),
|
||||
text: "Test message from integration test".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wait for response events (MessageStarted, SessionUpdate, MessageCompleted)
|
||||
let message_events_received = timeout(Duration::from_secs(30), async {
|
||||
let mut started = false;
|
||||
let mut parts = 0;
|
||||
let mut completed = false;
|
||||
|
||||
while let Ok(event) = events.recv().await {
|
||||
match event {
|
||||
dirigent_protocol::Event::MessageStarted { .. } => {
|
||||
println!("Received MessageStarted");
|
||||
started = true;
|
||||
}
|
||||
dirigent_protocol::Event::SessionUpdate { .. } => {
|
||||
parts += 1;
|
||||
}
|
||||
dirigent_protocol::Event::MessageCompleted { .. } => {
|
||||
println!("Received MessageCompleted after {} parts", parts);
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
dirigent_protocol::Event::Error { message } => {
|
||||
eprintln!("Error during message send: {}", message);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(started, parts, completed)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
message_events_received.is_ok(),
|
||||
"Should receive message response events"
|
||||
);
|
||||
|
||||
let (started, parts, completed) = message_events_received.unwrap();
|
||||
println!(
|
||||
"Message events: started={}, parts={}, completed={}",
|
||||
started, parts, completed
|
||||
);
|
||||
|
||||
assert!(started, "Should receive MessageStarted event");
|
||||
assert!(parts > 0, "Should receive at least one SessionUpdate event");
|
||||
assert!(completed, "Should receive MessageCompleted event");
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_t078_real_reconnect_command() {
|
||||
let Some(api_url) = get_test_api_url() else {
|
||||
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let connector = create_real_connector(&api_url);
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Wait for initial connection
|
||||
timeout(Duration::from_secs(10), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if matches!(event, dirigent_protocol::Event::Connected) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
assert_eq!(connector.state(), ConnectorState::Ready);
|
||||
|
||||
// Send Reconnect command
|
||||
let cmd_tx = connector.command_tx();
|
||||
cmd_tx.send(ConnectorCommand::Reconnect).await.unwrap();
|
||||
|
||||
// Should disconnect and reconnect
|
||||
let reconnected = timeout(Duration::from_secs(10), async {
|
||||
let mut saw_disconnect = false;
|
||||
while let Ok(event) = events.recv().await {
|
||||
match event {
|
||||
dirigent_protocol::Event::Disconnected => {
|
||||
println!("Saw disconnect");
|
||||
saw_disconnect = true;
|
||||
}
|
||||
dirigent_protocol::Event::Connected => {
|
||||
println!("Saw reconnect");
|
||||
if saw_disconnect {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
|
||||
// Note: The reconnect behavior may vary depending on the implementation
|
||||
// We just verify the command was processed without error
|
||||
println!(
|
||||
"Reconnect completed (saw reconnect cycle: {:?})",
|
||||
reconnected.ok()
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_t078_real_sse_stream_reliability() {
|
||||
let Some(api_url) = get_test_api_url() else {
|
||||
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let connector = create_real_connector(&api_url);
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Collect events for 5 seconds
|
||||
let events_collected = timeout(Duration::from_secs(5), async {
|
||||
let mut count = 0;
|
||||
let mut event_types = std::collections::HashMap::new();
|
||||
|
||||
while let Ok(event) = events.recv().await {
|
||||
count += 1;
|
||||
let type_name = match &event {
|
||||
dirigent_protocol::Event::Connected => "Connected",
|
||||
dirigent_protocol::Event::Disconnected => "Disconnected",
|
||||
dirigent_protocol::Event::SessionCreated { .. } => "SessionCreated",
|
||||
dirigent_protocol::Event::MessageStarted { .. } => "MessageStarted",
|
||||
dirigent_protocol::Event::SessionUpdate { .. } => "SessionUpdate",
|
||||
dirigent_protocol::Event::MessageCompleted { .. } => "MessageCompleted",
|
||||
dirigent_protocol::Event::Error { .. } => "Error",
|
||||
_ => "Other",
|
||||
};
|
||||
*event_types.entry(type_name).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
(count, event_types)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
events_collected.is_ok(),
|
||||
"Should collect events without timeout"
|
||||
);
|
||||
|
||||
let (count, event_types) = events_collected.unwrap();
|
||||
println!("Collected {} events:", count);
|
||||
for (type_name, count) in event_types {
|
||||
println!(" {}: {}", type_name, count);
|
||||
}
|
||||
|
||||
assert!(
|
||||
count > 0,
|
||||
"Should receive at least some events from SSE stream"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_t078_real_error_handling() {
|
||||
// Test with an invalid URL to verify error handling
|
||||
let connector = create_real_connector("http://invalid-hostname-that-does-not-exist:99999");
|
||||
let mut events = connector.subscribe();
|
||||
|
||||
// Start the connector
|
||||
let _task_handle = connector.start_task().await;
|
||||
|
||||
// Should receive error events
|
||||
let error_received = timeout(Duration::from_secs(10), async {
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let dirigent_protocol::Event::Error { message } = event {
|
||||
println!("Received expected error: {}", message);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
error_received.is_ok() && error_received.unwrap(),
|
||||
"Should receive error event"
|
||||
);
|
||||
|
||||
// State should be Error
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert!(
|
||||
matches!(connector.state(), ConnectorState::Error(_)),
|
||||
"State should be Error, got {:?}",
|
||||
connector.state()
|
||||
);
|
||||
|
||||
// Clean up
|
||||
connector.stop();
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Integration test: replay archived session into a `MockStream`.
|
||||
//!
|
||||
//! Builds a single-backend in-memory (tempdir) archivist, registers a
|
||||
//! session, appends 10 messages with ascending timestamps, then exercises
|
||||
//! `replay_session_to_stream` end-to-end.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_archivist::{
|
||||
Archivist, MessageRecord, RegisterConnectorRequest, RegisterSessionRequest,
|
||||
backends::JsonlBackend,
|
||||
};
|
||||
use dirigent_core::sharing::{
|
||||
MockStream,
|
||||
replay::{ReplayOptions, ReplaySpeed, replay_session_to_stream},
|
||||
};
|
||||
use dirigent_protocol::streaming::{EventOrigin, SessionStream, StreamScope};
|
||||
|
||||
/// Build an in-memory-ish archivist backed by a tempdir + JsonlBackend.
|
||||
///
|
||||
/// Matches the pattern used by `dirigent_archivist/tests/integration_tests.rs`.
|
||||
/// The tempdir is leaked for the duration of the test process — acceptable
|
||||
/// because the test binary exits immediately after.
|
||||
async fn build_in_memory_archivist() -> Arc<Archivist> {
|
||||
let temp_dir = std::env::temp_dir().join(format!("core_replay_test_{}", Uuid::now_v7()));
|
||||
let backend = Arc::new(
|
||||
JsonlBackend::new(temp_dir.clone())
|
||||
.await
|
||||
.expect("JsonlBackend construction"),
|
||||
);
|
||||
let archivist = Archivist::from_single_backend("main".into(), backend)
|
||||
.await
|
||||
.expect("Archivist::from_single_backend");
|
||||
Arc::new(archivist)
|
||||
}
|
||||
|
||||
/// Register a fresh connector + session and append `n` messages with
|
||||
/// timestamps one second apart. Returns the scroll_id.
|
||||
async fn seed_session_with_messages(archivist: &Archivist, n: usize) -> Uuid {
|
||||
let connector_resp = archivist
|
||||
.register_connector(
|
||||
RegisterConnectorRequest {
|
||||
r#type: "OpenCode".to_string(),
|
||||
title: "Replay Test Connector".to_string(),
|
||||
client_native_id: format!("replay-test@{}", Uuid::now_v7()),
|
||||
custom_uid: None,
|
||||
metadata: serde_json::json!({}),
|
||||
fingerprint: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register_connector");
|
||||
|
||||
let session_resp = archivist
|
||||
.register_session(
|
||||
RegisterSessionRequest {
|
||||
connector_uid: connector_resp.connector_uid,
|
||||
native_session_id: format!("native-{}", Uuid::now_v7()),
|
||||
title: Some("Replay Test Session".to_string()),
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::json!({}),
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register_session");
|
||||
|
||||
let scroll_id = session_resp.scroll_id;
|
||||
let base_ts = Utc::now();
|
||||
|
||||
let messages: Vec<MessageRecord> = (0..n)
|
||||
.map(|i| {
|
||||
let role = if i % 2 == 0 { "user" } else { "assistant" };
|
||||
MessageRecord {
|
||||
version: 1,
|
||||
message_id: Uuid::now_v7(),
|
||||
session: scroll_id,
|
||||
parent_id: None,
|
||||
ts: base_ts + ChronoDuration::seconds(i as i64),
|
||||
role: role.to_string(),
|
||||
author: None,
|
||||
content_md: format!("message {i}"),
|
||||
content_parts: None,
|
||||
attachments: vec![],
|
||||
metadata: serde_json::json!({}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
archivist
|
||||
.append_messages(scroll_id, messages, None)
|
||||
.await
|
||||
.expect("append_messages");
|
||||
|
||||
scroll_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_delivers_archived_messages_to_stream() {
|
||||
let archivist = build_in_memory_archivist().await;
|
||||
let scroll_id = seed_session_with_messages(&archivist, 10).await;
|
||||
|
||||
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
|
||||
let stream: Arc<dyn SessionStream> = mock.clone();
|
||||
|
||||
let report = replay_session_to_stream(
|
||||
archivist.as_ref(),
|
||||
scroll_id,
|
||||
stream,
|
||||
ReplayOptions {
|
||||
include_meta_events: false,
|
||||
speed: ReplaySpeed::AsFastAsPossible,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("replay_session_to_stream");
|
||||
|
||||
assert_eq!(report.events_sent, 10, "events_sent");
|
||||
assert_eq!(report.failures, 0, "failures");
|
||||
assert_eq!(mock.received_count(), 10, "mock received count");
|
||||
|
||||
let received = mock.received.lock().unwrap();
|
||||
for evt in received.iter() {
|
||||
assert!(
|
||||
matches!(evt.origin, EventOrigin::Replay { .. }),
|
||||
"every replayed event must carry EventOrigin::Replay"
|
||||
);
|
||||
assert_eq!(
|
||||
evt.routing.scroll_id,
|
||||
Some(scroll_id),
|
||||
"every replayed event must carry the authoritative scroll_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_continues_on_stream_failure() {
|
||||
let archivist = build_in_memory_archivist().await;
|
||||
let scroll_id = seed_session_with_messages(&archivist, 10).await;
|
||||
|
||||
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
|
||||
mock.fail_next(3);
|
||||
let stream: Arc<dyn SessionStream> = mock.clone();
|
||||
|
||||
let report = replay_session_to_stream(
|
||||
archivist.as_ref(),
|
||||
scroll_id,
|
||||
stream,
|
||||
ReplayOptions {
|
||||
include_meta_events: false,
|
||||
speed: ReplaySpeed::AsFastAsPossible,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("replay_session_to_stream");
|
||||
|
||||
// events_sent counts attempted (ok + failed); failures counts Failed only.
|
||||
assert_eq!(report.events_sent, 10, "events_sent counts every attempt");
|
||||
assert_eq!(report.failures, 3, "first 3 events rejected by mock");
|
||||
assert_eq!(
|
||||
mock.received_count(),
|
||||
7,
|
||||
"mock buffer contains the 7 successful events"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
#![cfg(feature = "server")]
|
||||
//! Tests for CoreRuntime operations
|
||||
//!
|
||||
//! T072: CoreRuntime::create_connector
|
||||
//! T073: CoreRuntime::start_connector
|
||||
//! T074: CoreRuntime::stop_connector
|
||||
//! T075: CoreRuntime::send_command
|
||||
|
||||
use dirigent_core::connectors::{Connector, ConnectorCommand};
|
||||
use dirigent_core::types::{ConnectorKind, ConnectorState};
|
||||
use dirigent_core::{ConnectorConfig, CoreConfig, CoreError, CoreRuntime};
|
||||
use serde_json::json;
|
||||
|
||||
/// Helper to create a test runtime
|
||||
fn create_test_runtime() -> CoreRuntime {
|
||||
CoreRuntime::new(CoreConfig::default(), None)
|
||||
}
|
||||
|
||||
/// Helper to create an OpenCode connector config
|
||||
fn create_opencode_config(id: Option<String>, title: &str) -> ConnectorConfig {
|
||||
ConnectorConfig {
|
||||
id,
|
||||
kind: ConnectorKind::OpenCode,
|
||||
owner: None,
|
||||
title: Some(title.to_string()),
|
||||
working_directory: None,
|
||||
params: json!({
|
||||
"base_url": "http://localhost:12225",
|
||||
"title": title,
|
||||
"initial_session": null
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T072: CoreRuntime::create_connector
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t072_connector_id_auto_generated_if_not_provided() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(None, "Auto ID Test");
|
||||
|
||||
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
|
||||
|
||||
assert!(result.is_ok(), "Should create connector successfully");
|
||||
|
||||
let connector_id = result.unwrap();
|
||||
assert!(!connector_id.is_empty(), "Generated ID should not be empty");
|
||||
|
||||
// Verify it's a valid UUID format (36 chars with hyphens)
|
||||
assert_eq!(connector_id.len(), 36, "Should be UUID format");
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t072_connector_uses_provided_id() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("my-custom-id".to_string()), "Custom ID Test");
|
||||
|
||||
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
|
||||
|
||||
assert!(result.is_ok(), "Should create connector successfully");
|
||||
assert_eq!(result.unwrap(), "my-custom-id");
|
||||
|
||||
// Clean up
|
||||
runtime
|
||||
.remove_connector(&"my-custom-id".to_string())
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t072_already_exists_error_if_id_conflicts() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg1 = create_opencode_config(Some("duplicate-id".to_string()), "First");
|
||||
|
||||
// Create first connector
|
||||
let result1 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg1.clone())
|
||||
.await;
|
||||
assert!(result1.is_ok(), "First creation should succeed");
|
||||
|
||||
// Try to create another with the same ID
|
||||
let result2 = runtime.create_connector(uuid::Uuid::nil(), cfg1).await;
|
||||
|
||||
assert!(result2.is_err(), "Second creation should fail");
|
||||
assert_eq!(result2.unwrap_err(), CoreError::AlreadyExists);
|
||||
|
||||
// Clean up
|
||||
runtime
|
||||
.remove_connector(&"duplicate-id".to_string())
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t072_connector_appears_in_list_after_creation() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Initially empty
|
||||
let list_before = runtime.list_connectors(None).await;
|
||||
let initial_count = list_before.len();
|
||||
|
||||
// Create a connector
|
||||
let cfg = create_opencode_config(Some("test-conn-1".to_string()), "Test 1");
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// List should now contain it
|
||||
let list_after = runtime.list_connectors(None).await;
|
||||
assert_eq!(list_after.len(), initial_count + 1);
|
||||
|
||||
let found = list_after.iter().find(|c| c.id == connector_id);
|
||||
assert!(found.is_some(), "Created connector should be in list");
|
||||
|
||||
let connector_summary = found.unwrap();
|
||||
assert_eq!(connector_summary.id, "test-conn-1");
|
||||
assert_eq!(connector_summary.title, "Test 1");
|
||||
assert_eq!(connector_summary.owner, uuid::Uuid::nil());
|
||||
assert_eq!(connector_summary.kind, ConnectorKind::OpenCode);
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t072_invalid_config_returns_error() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let cfg = ConnectorConfig {
|
||||
id: None,
|
||||
kind: ConnectorKind::OpenCode,
|
||||
owner: None,
|
||||
title: Some("Invalid".to_string()),
|
||||
working_directory: None,
|
||||
params: json!({
|
||||
"invalid": "config"
|
||||
// Missing required fields like base_url
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
|
||||
|
||||
assert!(result.is_err(), "Should fail with invalid config");
|
||||
assert_eq!(result.unwrap_err(), CoreError::InvalidConfig);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t072_mock_connector_not_allowed() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let cfg = ConnectorConfig {
|
||||
id: None,
|
||||
kind: ConnectorKind::Mock,
|
||||
owner: None,
|
||||
title: Some("Mock".to_string()),
|
||||
working_directory: None,
|
||||
params: json!({}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Mock connectors should not be creatable via API"
|
||||
);
|
||||
assert_eq!(result.unwrap_err(), CoreError::InvalidConfig);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t072_owner_override() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let mut cfg = create_opencode_config(None, "Owner Test");
|
||||
// Try to set owner in config
|
||||
cfg.owner = Some(uuid::Uuid::from_u128(99));
|
||||
|
||||
// Create with different owner
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::from_u128(42), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify owner was overridden
|
||||
let list = runtime.list_connectors(None).await;
|
||||
let connector = list.iter().find(|c| c.id == connector_id).unwrap();
|
||||
assert_eq!(
|
||||
connector.owner,
|
||||
uuid::Uuid::from_u128(42),
|
||||
"Owner should be from parameter, not config"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T073: CoreRuntime::start_connector
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t073_start_connector_not_found() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let result = runtime.start_connector(&"nonexistent".to_string()).await;
|
||||
|
||||
assert!(result.is_err(), "Should fail for nonexistent connector");
|
||||
assert_eq!(result.unwrap_err(), CoreError::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t073_start_connector_not_yet_implemented() {
|
||||
// Note: As per the runtime.rs code, starting an existing connector
|
||||
// is not yet fully implemented. This test documents the current behavior.
|
||||
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("start-test".to_string()), "Start Test");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to start it
|
||||
let result = runtime.start_connector(&connector_id).await;
|
||||
|
||||
// Currently returns an error indicating not implemented
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Starting existing connector not yet supported"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T074: CoreRuntime::stop_connector
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t074_stop_connector_not_found() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let result = runtime.stop_connector(&"nonexistent".to_string()).await;
|
||||
|
||||
assert!(result.is_err(), "Should fail for nonexistent connector");
|
||||
assert_eq!(result.unwrap_err(), CoreError::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t074_stop_connector_changes_state_to_stopped() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("stop-test".to_string()), "Stop Test");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Get the connector and verify initial state
|
||||
let connector = runtime.get_connector(&connector_id).await.unwrap();
|
||||
let initial_state = connector.state();
|
||||
assert_eq!(initial_state, ConnectorState::Initializing);
|
||||
|
||||
// Stop it
|
||||
let result = runtime.stop_connector(&connector_id).await;
|
||||
assert!(result.is_ok(), "Stop should succeed");
|
||||
|
||||
// Verify state changed to Stopped
|
||||
let connector = runtime.get_connector(&connector_id).await.unwrap();
|
||||
let final_state = connector.state();
|
||||
assert_eq!(final_state, ConnectorState::Stopped);
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t074_stop_connector_idempotent() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("stop-twice".to_string()), "Stop Twice");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Stop once
|
||||
let result1 = runtime.stop_connector(&connector_id).await;
|
||||
assert!(result1.is_ok(), "First stop should succeed");
|
||||
|
||||
// Stop again
|
||||
let result2 = runtime.stop_connector(&connector_id).await;
|
||||
assert!(result2.is_ok(), "Second stop should also succeed");
|
||||
|
||||
// State should still be Stopped
|
||||
let connector = runtime.get_connector(&connector_id).await.unwrap();
|
||||
assert_eq!(connector.state(), ConnectorState::Stopped);
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// T075: CoreRuntime::send_command
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_t075_send_command_not_found() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let result = runtime
|
||||
.send_command(&"nonexistent".to_string(), ConnectorCommand::ListSessions)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "Should fail for nonexistent connector");
|
||||
assert_eq!(result.unwrap_err(), CoreError::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // TODO: Fix - cmd_rx is dropped when connector is dropped in create_connector
|
||||
async fn test_t075_send_command_to_connector() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("cmd-test".to_string()), "Command Test");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Get the connector and subscribe to events
|
||||
let connector = runtime.get_connector(&connector_id).await.unwrap();
|
||||
let _events = connector.subscribe();
|
||||
|
||||
// Send a command via runtime
|
||||
let result = runtime
|
||||
.send_command(&connector_id, ConnectorCommand::ListSessions)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Send command should succeed");
|
||||
|
||||
// Note: The command won't be processed until the connector is started,
|
||||
// but we've verified that the command was accepted
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
#[ignore] // TODO: Fix - cmd_rx is dropped when connector is dropped in create_connector
|
||||
#[tokio::test]
|
||||
async fn test_t075_send_all_command_types() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("all-cmds".to_string()), "All Commands");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send ListSessions
|
||||
let result = runtime
|
||||
.send_command(&connector_id, ConnectorCommand::ListSessions)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Send ListMessages
|
||||
let result = runtime
|
||||
.send_command(
|
||||
&connector_id,
|
||||
ConnectorCommand::ListMessages {
|
||||
session_id: "test-session".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Send SendMessage
|
||||
let result = runtime
|
||||
.send_command(
|
||||
&connector_id,
|
||||
ConnectorCommand::SendMessage {
|
||||
session_id: "test-session".to_string(),
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Send Reconnect
|
||||
let result = runtime
|
||||
.send_command(&connector_id, ConnectorCommand::Reconnect)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Send Shutdown
|
||||
let result = runtime
|
||||
.send_command(&connector_id, ConnectorCommand::Shutdown)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // TODO: Fix - cmd_rx is dropped when connector is dropped in create_connector
|
||||
async fn test_t075_send_command_channel_capacity() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("capacity-test".to_string()), "Capacity Test");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send many commands (the channel has capacity 100)
|
||||
for i in 0..50 {
|
||||
let result = runtime
|
||||
.send_command(&connector_id, ConnectorCommand::ListSessions)
|
||||
.await;
|
||||
assert!(result.is_ok(), "Command {} should be accepted", i);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional Runtime Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_connectors_filters_by_owner() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Create connectors for different users
|
||||
let cfg1 = create_opencode_config(Some("user1-conn1".to_string()), "User 1 Conn 1");
|
||||
let cfg2 = create_opencode_config(Some("user1-conn2".to_string()), "User 1 Conn 2");
|
||||
let cfg3 = create_opencode_config(Some("user2-conn1".to_string()), "User 2 Conn 1");
|
||||
|
||||
runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg1)
|
||||
.await
|
||||
.unwrap();
|
||||
runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg2)
|
||||
.await
|
||||
.unwrap();
|
||||
runtime
|
||||
.create_connector(uuid::Uuid::from_u128(2), cfg3)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// List all
|
||||
let all = runtime.list_connectors(None).await;
|
||||
assert!(all.len() >= 3, "Should have at least 3 connectors");
|
||||
|
||||
// List for user-1
|
||||
let user1_list = runtime.list_connectors(Some(uuid::Uuid::nil())).await;
|
||||
assert_eq!(user1_list.len(), 2, "User 1 should have 2 connectors");
|
||||
|
||||
// List for user-2
|
||||
let user2_list = runtime
|
||||
.list_connectors(Some(uuid::Uuid::from_u128(2)))
|
||||
.await;
|
||||
assert_eq!(user2_list.len(), 1, "User 2 should have 1 connector");
|
||||
|
||||
// Clean up
|
||||
runtime
|
||||
.remove_connector(&"user1-conn1".to_string())
|
||||
.await
|
||||
.ok();
|
||||
runtime
|
||||
.remove_connector(&"user1-conn2".to_string())
|
||||
.await
|
||||
.ok();
|
||||
runtime
|
||||
.remove_connector(&"user2-conn1".to_string())
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_connector_returns_some_if_exists() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("get-test".to_string()), "Get Test");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = runtime.get_connector(&connector_id).await;
|
||||
assert!(result.is_some(), "Should find connector");
|
||||
|
||||
let connector = result.unwrap();
|
||||
assert_eq!(connector.id(), &connector_id);
|
||||
assert_eq!(connector.title(), "Get Test");
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&connector_id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_connector_returns_none_if_not_exists() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
let result = runtime.get_connector(&"nonexistent".to_string()).await;
|
||||
assert!(result.is_none(), "Should not find nonexistent connector");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_connector_success() {
|
||||
let runtime = create_test_runtime();
|
||||
let cfg = create_opencode_config(Some("remove-test".to_string()), "Remove Test");
|
||||
|
||||
let connector_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify it exists
|
||||
assert!(runtime.get_connector(&connector_id).await.is_some());
|
||||
|
||||
// Remove it
|
||||
let result = runtime.remove_connector(&connector_id).await;
|
||||
assert!(result.is_ok(), "Remove should succeed");
|
||||
|
||||
// Verify it's gone
|
||||
assert!(runtime.get_connector(&connector_id).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sharing_bus_subscribe_all() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Subscribe to every event on the SharingBus — this is the
|
||||
// replacement for the retired `subscribe_global()` API.
|
||||
let rx1 = runtime.sharing_bus().subscribe_all().await;
|
||||
let rx2 = runtime.sharing_bus().subscribe_all().await;
|
||||
|
||||
// Verify both subscriptions are valid
|
||||
drop(rx1);
|
||||
drop(rx2);
|
||||
// If this compiles and runs, subscriptions work
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Issue 2: Zero-Connector State (Regression Test)
|
||||
// ============================================================================
|
||||
|
||||
/// Test that new connectors can be added after removing all existing connectors
|
||||
///
|
||||
/// This test verifies the fix for Issue 2: "Removing All Connectors Breaks New Connection"
|
||||
///
|
||||
/// Regression test ensures:
|
||||
/// - Creating first connector works (0 -> 1 transition)
|
||||
/// - Removing all connectors works (1 -> 0 transition)
|
||||
/// - Creating connector after zero state works (0 -> 1 again)
|
||||
/// - No state corruption or race conditions
|
||||
/// - Config persistence handles empty state correctly
|
||||
#[tokio::test]
|
||||
async fn test_issue_2_create_connector_after_removing_all() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Verify starting state is empty
|
||||
let initial_connectors = runtime.list_connectors(None).await;
|
||||
assert_eq!(
|
||||
initial_connectors.len(),
|
||||
0,
|
||||
"Should start with zero connectors"
|
||||
);
|
||||
|
||||
// Create first connector (0 -> 1 transition)
|
||||
let cfg1 = create_opencode_config(Some("test-connector-1".to_string()), "First Connector");
|
||||
let id1 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg1)
|
||||
.await
|
||||
.expect("Should create first connector");
|
||||
|
||||
let connectors_after_create = runtime.list_connectors(None).await;
|
||||
assert_eq!(
|
||||
connectors_after_create.len(),
|
||||
1,
|
||||
"Should have 1 connector after creation"
|
||||
);
|
||||
|
||||
// Verify connector is in the list
|
||||
let connector1 = runtime.get_connector(&id1).await;
|
||||
assert!(connector1.is_some(), "First connector should exist");
|
||||
|
||||
// Remove the connector (1 -> 0 transition)
|
||||
runtime
|
||||
.remove_connector(&id1)
|
||||
.await
|
||||
.expect("Should remove connector successfully");
|
||||
|
||||
let connectors_after_remove = runtime.list_connectors(None).await;
|
||||
assert_eq!(
|
||||
connectors_after_remove.len(),
|
||||
0,
|
||||
"Should have zero connectors after removal"
|
||||
);
|
||||
|
||||
// Verify connector is gone
|
||||
let connector1_after_remove = runtime.get_connector(&id1).await;
|
||||
assert!(
|
||||
connector1_after_remove.is_none(),
|
||||
"First connector should not exist after removal"
|
||||
);
|
||||
|
||||
// Create new connector after reaching zero state (0 -> 1 again)
|
||||
let cfg2 = create_opencode_config(Some("test-connector-2".to_string()), "Second Connector");
|
||||
let id2 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg2)
|
||||
.await
|
||||
.expect("Should create connector after removing all");
|
||||
|
||||
let connectors_final = runtime.list_connectors(None).await;
|
||||
assert_eq!(
|
||||
connectors_final.len(),
|
||||
1,
|
||||
"Should have 1 connector after recreation"
|
||||
);
|
||||
|
||||
// Verify new connector is in the list
|
||||
let connector2 = runtime.get_connector(&id2).await;
|
||||
assert!(connector2.is_some(), "Second connector should exist");
|
||||
|
||||
// Verify IDs are different
|
||||
assert_ne!(id1, id2, "New connector should have different ID");
|
||||
|
||||
// Verify new connector is functional (state check)
|
||||
let connector2_handle = connector2.unwrap();
|
||||
let state = connector2_handle.state();
|
||||
assert_eq!(
|
||||
state,
|
||||
ConnectorState::Initializing,
|
||||
"New connector should be initializing"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&id2).await.ok();
|
||||
}
|
||||
|
||||
/// Test rapid remove-create cycles to check for race conditions
|
||||
///
|
||||
/// This test verifies that repeated transitions between empty and non-empty
|
||||
/// connector states don't cause issues with config persistence or internal state.
|
||||
#[tokio::test]
|
||||
async fn test_issue_2_rapid_remove_create_cycles() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Perform 5 cycles of create -> remove
|
||||
for i in 0..5 {
|
||||
let cfg = create_opencode_config(
|
||||
Some(format!("cycle-connector-{}", i)),
|
||||
&format!("Cycle Test {}", i),
|
||||
);
|
||||
|
||||
// Create connector
|
||||
let id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.expect(&format!("Should create connector in cycle {}", i));
|
||||
|
||||
// Verify it exists
|
||||
let connectors = runtime.list_connectors(None).await;
|
||||
assert_eq!(
|
||||
connectors.len(),
|
||||
1,
|
||||
"Should have 1 connector after creation"
|
||||
);
|
||||
|
||||
// Remove connector
|
||||
runtime
|
||||
.remove_connector(&id)
|
||||
.await
|
||||
.expect(&format!("Should remove connector in cycle {}", i));
|
||||
|
||||
// Verify empty state
|
||||
let connectors = runtime.list_connectors(None).await;
|
||||
assert_eq!(
|
||||
connectors.len(),
|
||||
0,
|
||||
"Should have 0 connectors after removal"
|
||||
);
|
||||
}
|
||||
|
||||
// Final verification: Create one more connector to ensure state is still valid
|
||||
let final_cfg = create_opencode_config(Some("final-connector".to_string()), "Final Test");
|
||||
let final_id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), final_cfg)
|
||||
.await
|
||||
.expect("Should create connector after rapid cycles");
|
||||
|
||||
let final_connectors = runtime.list_connectors(None).await;
|
||||
assert_eq!(final_connectors.len(), 1, "Should have 1 connector at end");
|
||||
|
||||
// Clean up
|
||||
runtime.remove_connector(&final_id).await.ok();
|
||||
}
|
||||
|
||||
/// Test that SharingBus subscriptions work across zero-connector transitions.
|
||||
///
|
||||
/// Ensures SSE subscriptions remain valid when all connectors are removed
|
||||
/// and new connectors are added.
|
||||
#[tokio::test]
|
||||
async fn test_issue_2_global_events_survive_zero_connectors() {
|
||||
let runtime = create_test_runtime();
|
||||
|
||||
// Subscribe to events before any connectors exist.
|
||||
let _rx = runtime.sharing_bus().subscribe_all().await;
|
||||
|
||||
// Create connector
|
||||
let cfg = create_opencode_config(Some("event-test".to_string()), "Event Test");
|
||||
let id = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg)
|
||||
.await
|
||||
.expect("Should create connector");
|
||||
|
||||
// Remove connector (transition to zero)
|
||||
runtime
|
||||
.remove_connector(&id)
|
||||
.await
|
||||
.expect("Should remove connector");
|
||||
|
||||
// Subscribe again after zero state
|
||||
let _rx2 = runtime.sharing_bus().subscribe_all().await;
|
||||
|
||||
// Create another connector
|
||||
let cfg2 = create_opencode_config(Some("event-test-2".to_string()), "Event Test 2");
|
||||
let id2 = runtime
|
||||
.create_connector(uuid::Uuid::nil(), cfg2)
|
||||
.await
|
||||
.expect("Should create connector after zero state");
|
||||
|
||||
// If we get here, subscriptions survived the zero-connector transition
|
||||
// Clean up
|
||||
runtime.remove_connector(&id2).await.ok();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Integration test for SharingBus late-bind and filter routing.
|
||||
//!
|
||||
//! Exercises the full publish cycle: subscribe with two different filters,
|
||||
//! publish three events (one before cache population, one that populates the
|
||||
//! cache, one that is late-bound from the cache), then assert each subscriber
|
||||
//! saw exactly the events it should.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_core::sharing::bus::SharingBus;
|
||||
use dirigent_protocol::{
|
||||
Message, MessageRole, MessageStatus, Session, SessionMetadata,
|
||||
conversation::MessagePart,
|
||||
streaming::{BusEvent, EventFilter},
|
||||
Event,
|
||||
};
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_session(id: &str) -> Session {
|
||||
let now = chrono::Utc::now();
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
title: "test-session".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: SessionMetadata {
|
||||
project_path: "/tmp/test".to_string(),
|
||||
model: None,
|
||||
total_messages: 0,
|
||||
system_message: None,
|
||||
current_mode_id: None,
|
||||
_meta: None,
|
||||
project_id: None,
|
||||
},
|
||||
cwd: None,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
acp_client_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_message(session_id: &str, msg_id: &str) -> Message {
|
||||
let now = chrono::Utc::now();
|
||||
Message {
|
||||
id: msg_id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
role: MessageRole::Assistant,
|
||||
created_at: now,
|
||||
content: vec![MessagePart::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
status: MessageStatus::Completed,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Full late-bind cycle:
|
||||
///
|
||||
/// - `scroll_rx` is filtered by `EventFilter::ScrollId(scroll_id)`.
|
||||
/// It should receive events #2 and #3 only (not #1, because the cache was
|
||||
/// empty when #1 was published).
|
||||
///
|
||||
/// - `uid_rx` is filtered by `EventFilter::ConnectorUid(uid)`.
|
||||
/// It should receive all three events because `connector_uid` is set on
|
||||
/// every event by `BusEvent::from_connector_event`.
|
||||
#[tokio::test]
|
||||
async fn scroll_id_late_bind_via_session_registered() {
|
||||
let bus = SharingBus::new();
|
||||
|
||||
let uid = Uuid::new_v4();
|
||||
let scroll_id = Uuid::new_v4();
|
||||
|
||||
let mut scroll_rx = bus
|
||||
.subscribe_filtered(EventFilter::ScrollId(scroll_id), 32)
|
||||
.await;
|
||||
let mut uid_rx = bus
|
||||
.subscribe_filtered(EventFilter::ConnectorUid(uid), 32)
|
||||
.await;
|
||||
|
||||
// Event #1: SessionCreated — cache is empty, scroll_id will not be
|
||||
// late-bound. The ScrollId subscriber must NOT see this event.
|
||||
bus.publish(BusEvent::from_connector_event(
|
||||
Event::SessionCreated {
|
||||
connector_id: "mock".into(),
|
||||
session: make_session("abc"),
|
||||
},
|
||||
Some(uid),
|
||||
"mock".into(),
|
||||
))
|
||||
.await;
|
||||
|
||||
// Event #2: SessionRegistered — populates the cache
|
||||
// (`connector_id="mock"`, `session_id="abc"`) → `scroll_id`.
|
||||
// The bus also sets `routing.scroll_id` on this event itself, so the
|
||||
// ScrollId subscriber DOES see it.
|
||||
bus.publish(BusEvent::from_connector_event(
|
||||
Event::SessionRegistered {
|
||||
connector_id: "mock".into(),
|
||||
session_id: "abc".into(),
|
||||
scroll_id: scroll_id.to_string(),
|
||||
},
|
||||
Some(uid),
|
||||
"mock".into(),
|
||||
))
|
||||
.await;
|
||||
|
||||
// Event #3: MessageCompleted — no scroll_id on entry, but the bus
|
||||
// late-binds it from the cache via (connector_id="mock", session_id="abc").
|
||||
// The ScrollId subscriber DOES see it.
|
||||
bus.publish(BusEvent::from_connector_event(
|
||||
Event::MessageCompleted {
|
||||
connector_id: "mock".into(),
|
||||
message: make_message("abc", "m1"),
|
||||
},
|
||||
Some(uid),
|
||||
"mock".into(),
|
||||
))
|
||||
.await;
|
||||
|
||||
// Give the bus worker time to dispatch all three events.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// Drain what each subscriber received.
|
||||
let mut scroll_events: Vec<BusEvent> = Vec::new();
|
||||
while let Ok(Some(e)) =
|
||||
tokio::time::timeout(Duration::from_millis(20), scroll_rx.rx.recv()).await
|
||||
{
|
||||
scroll_events.push(e);
|
||||
}
|
||||
|
||||
let mut uid_events: Vec<BusEvent> = Vec::new();
|
||||
while let Ok(Some(e)) =
|
||||
tokio::time::timeout(Duration::from_millis(20), uid_rx.rx.recv()).await
|
||||
{
|
||||
uid_events.push(e);
|
||||
}
|
||||
|
||||
// ScrollId subscriber: only events #2 and #3.
|
||||
assert_eq!(
|
||||
scroll_events.len(),
|
||||
2,
|
||||
"ScrollId subscriber should see events #2 (SessionRegistered) and #3 (MessageCompleted) only; got {:?}",
|
||||
scroll_events.iter().map(|e| format!("{:?}", e.event)).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
matches!(scroll_events[0].event.as_ref(), Event::SessionRegistered { .. }),
|
||||
"first scroll event should be SessionRegistered"
|
||||
);
|
||||
assert!(
|
||||
matches!(scroll_events[1].event.as_ref(), Event::MessageCompleted { .. }),
|
||||
"second scroll event should be MessageCompleted"
|
||||
);
|
||||
// Both must carry the correct scroll_id.
|
||||
assert_eq!(scroll_events[0].routing.scroll_id, Some(scroll_id));
|
||||
assert_eq!(scroll_events[1].routing.scroll_id, Some(scroll_id));
|
||||
|
||||
// ConnectorUid subscriber: all three.
|
||||
assert_eq!(
|
||||
uid_events.len(),
|
||||
3,
|
||||
"ConnectorUid subscriber should see all three events; got {:?}",
|
||||
uid_events.iter().map(|e| format!("{:?}", e.event)).collect::<Vec<_>>()
|
||||
);
|
||||
for ev in &uid_events {
|
||||
assert_eq!(ev.routing.connector_uid, Some(uid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Integration tests for `StreamRegistry`: scope-based filtering, health
|
||||
//! drift, and `detach()` shutdown semantics.
|
||||
//!
|
||||
//! Uses the `MockStream` from `dirigent_core::sharing` (enabled via the
|
||||
//! `test-utils` feature; see `required-features` in `Cargo.toml`).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_core::sharing::MockStream;
|
||||
use dirigent_core::sharing::bus::SharingBus;
|
||||
use dirigent_core::sharing::registry::StreamRegistry;
|
||||
use dirigent_protocol::{
|
||||
Event,
|
||||
streaming::{
|
||||
BusEvent, EventKind, EventOrigin, EventRouting, StreamScope,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a `BusEvent` with an explicit `scroll_id` in the routing so the
|
||||
/// bus does not need to consult its late-bind cache. `connector_uid` is
|
||||
/// populated too so tests can mix-and-match filters.
|
||||
fn make_scoped_event(scroll_id: Uuid, connector_uid: Uuid) -> BusEvent {
|
||||
BusEvent {
|
||||
routing: EventRouting {
|
||||
scroll_id: Some(scroll_id),
|
||||
connector_uid: Some(connector_uid),
|
||||
connector_id: Some("conn-test".to_string()),
|
||||
native_session_id: None,
|
||||
kind: EventKind::System,
|
||||
},
|
||||
origin: EventOrigin::Runtime,
|
||||
event: Arc::new(Event::Connected),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_stream_receives_only_session_scoped_events() {
|
||||
let bus = SharingBus::new();
|
||||
let registry = StreamRegistry::new(Arc::clone(&bus));
|
||||
|
||||
let scroll_x = Uuid::now_v7();
|
||||
let scroll_y = Uuid::now_v7();
|
||||
let connector_uid = Uuid::now_v7();
|
||||
|
||||
let mock = MockStream::new("mock", StreamScope::Session {
|
||||
scroll_id: scroll_x,
|
||||
});
|
||||
let _id = registry.attach("mock".to_string(), mock.clone()).await;
|
||||
|
||||
// One event in-scope (scroll_x), two out-of-scope (scroll_y).
|
||||
bus.publish(make_scoped_event(scroll_x, connector_uid))
|
||||
.await;
|
||||
bus.publish(make_scoped_event(scroll_y, connector_uid))
|
||||
.await;
|
||||
bus.publish(make_scoped_event(scroll_y, connector_uid))
|
||||
.await;
|
||||
|
||||
// Give the bus worker + stream worker a chance to process.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let count = mock.received_count();
|
||||
assert_eq!(
|
||||
count,
|
||||
1,
|
||||
"expected 1 in-scope event, got {count}",
|
||||
);
|
||||
assert_eq!(
|
||||
mock.received.lock().unwrap()[0].routing.scroll_id,
|
||||
Some(scroll_x)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_drifts_to_unavailable_after_five_failures() {
|
||||
use dirigent_core::sharing::HealthStatus;
|
||||
|
||||
let bus = SharingBus::new();
|
||||
let registry = StreamRegistry::new(Arc::clone(&bus));
|
||||
|
||||
let scroll_id = Uuid::now_v7();
|
||||
let connector_uid = Uuid::now_v7();
|
||||
|
||||
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
|
||||
mock.fail_next(5);
|
||||
let _id = registry.attach("mock".to_string(), mock.clone()).await;
|
||||
|
||||
// Five consecutive failures should drift Healthy → Unavailable.
|
||||
for _ in 0..5 {
|
||||
bus.publish(make_scoped_event(scroll_id, connector_uid))
|
||||
.await;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let infos = registry.list().await;
|
||||
assert_eq!(infos.len(), 1);
|
||||
match &infos[0].health {
|
||||
HealthStatus::Unavailable { reason } => {
|
||||
assert!(
|
||||
reason.contains("5 failures"),
|
||||
"expected reason to mention the failure count, got: {reason}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Unavailable after 5 failures, got {:?}", other),
|
||||
}
|
||||
assert_eq!(infos[0].lagged_count, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detach_invokes_shutdown_and_stops_delivery() {
|
||||
let bus = SharingBus::new();
|
||||
let registry = StreamRegistry::new(Arc::clone(&bus));
|
||||
|
||||
let scroll_id = Uuid::now_v7();
|
||||
let connector_uid = Uuid::now_v7();
|
||||
|
||||
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
|
||||
let id = registry.attach("mock".to_string(), mock.clone()).await;
|
||||
|
||||
// One in-scope event before detach to prove delivery works at all.
|
||||
bus.publish(make_scoped_event(scroll_id, connector_uid))
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(mock.received_count(), 1, "pre-detach delivery failed");
|
||||
|
||||
// Detach — worker should stop, shutdown should run.
|
||||
let reg = registry.detach(id).await.expect("stream should exist");
|
||||
// Wait for the worker to finish so we know the post-detach publish
|
||||
// cannot possibly reach the stream.
|
||||
let _ = tokio::time::timeout(Duration::from_millis(500), async {
|
||||
let handle = ®.worker;
|
||||
while !handle.is_finished() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
// Publish post-detach; the stream must not receive it.
|
||||
bus.publish(make_scoped_event(scroll_id, connector_uid))
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
assert_eq!(
|
||||
mock.received_count(),
|
||||
1,
|
||||
"stream received an event after detach"
|
||||
);
|
||||
assert!(registry.list().await.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Integration tests for ACP transport layer.
|
||||
//!
|
||||
//! These tests verify both stdio and HTTP transport implementations work correctly.
|
||||
|
||||
use dirigent_core::acp::transport::{
|
||||
JsonRpcError, JsonRpcErrorResponse, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
|
||||
JsonRpcResult, TransportError, TransportState,
|
||||
};
|
||||
|
||||
/// Test JSON-RPC request creation and serialization.
|
||||
#[test]
|
||||
fn test_jsonrpc_request() {
|
||||
let req = JsonRpcRequest::new(
|
||||
1,
|
||||
"test_method",
|
||||
Some(serde_json::json!({"param1": "value1"})),
|
||||
);
|
||||
|
||||
assert_eq!(req.jsonrpc, "2.0");
|
||||
assert_eq!(req.id, serde_json::Value::Number(1.into()));
|
||||
assert_eq!(req.method, "test_method");
|
||||
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"id\":1"));
|
||||
assert!(json.contains("\"method\":\"test_method\""));
|
||||
assert!(json.contains("\"param1\":\"value1\""));
|
||||
}
|
||||
|
||||
/// Test JSON-RPC notification creation and serialization.
|
||||
#[test]
|
||||
fn test_jsonrpc_notification() {
|
||||
let notif = JsonRpcNotification::new(
|
||||
"test_notification",
|
||||
Some(serde_json::json!({"data": "test"})),
|
||||
);
|
||||
|
||||
assert_eq!(notif.jsonrpc, "2.0");
|
||||
assert_eq!(notif.method, "test_notification");
|
||||
|
||||
let json = serde_json::to_string(¬if).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"method\":\"test_notification\""));
|
||||
// Notifications don't have an "id" field
|
||||
assert!(!json.contains("\"id\""));
|
||||
}
|
||||
|
||||
/// Test JSON-RPC response deserialization.
|
||||
#[test]
|
||||
fn test_jsonrpc_response_deserialization() {
|
||||
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok"}}"#;
|
||||
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(response.jsonrpc, "2.0");
|
||||
assert_eq!(response.id, serde_json::Value::Number(1.into()));
|
||||
assert_eq!(response.result.get("status").unwrap(), "ok");
|
||||
}
|
||||
|
||||
/// Test JSON-RPC error response deserialization.
|
||||
#[test]
|
||||
fn test_jsonrpc_error_response_deserialization() {
|
||||
let json = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#;
|
||||
let error_response: JsonRpcErrorResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(error_response.jsonrpc, "2.0");
|
||||
assert_eq!(error_response.id, serde_json::Value::Number(1.into()));
|
||||
assert_eq!(error_response.error.code, -32600);
|
||||
assert_eq!(error_response.error.message, "Invalid Request");
|
||||
}
|
||||
|
||||
/// Test JsonRpcResult helper methods.
|
||||
#[test]
|
||||
fn test_jsonrpc_result_helpers() {
|
||||
// Test success result
|
||||
let success_response = JsonRpcResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Number(1.into()),
|
||||
result: serde_json::json!({"status": "ok"}),
|
||||
};
|
||||
let result = JsonRpcResult::Success(success_response);
|
||||
|
||||
assert!(result.is_success());
|
||||
assert!(!result.is_error());
|
||||
assert!(result.result().is_some());
|
||||
assert!(result.error().is_none());
|
||||
assert_eq!(result.result().unwrap().get("status").unwrap(), "ok");
|
||||
|
||||
// Test error result
|
||||
let error_response = JsonRpcErrorResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Number(2.into()),
|
||||
error: JsonRpcError {
|
||||
code: -32600,
|
||||
message: "Invalid Request".to_string(),
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
let result = JsonRpcResult::Error(error_response);
|
||||
|
||||
assert!(!result.is_success());
|
||||
assert!(result.is_error());
|
||||
assert!(result.result().is_none());
|
||||
assert!(result.error().is_some());
|
||||
assert_eq!(result.error().unwrap().code, -32600);
|
||||
}
|
||||
|
||||
/// Test JsonRpcResult conversion to Result.
|
||||
#[test]
|
||||
fn test_jsonrpc_result_into_result() {
|
||||
// Success case
|
||||
let success_response = JsonRpcResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Number(1.into()),
|
||||
result: serde_json::json!({"status": "ok"}),
|
||||
};
|
||||
let result = JsonRpcResult::Success(success_response);
|
||||
let converted = result.into_result();
|
||||
assert!(converted.is_ok());
|
||||
|
||||
// Error case
|
||||
let error_response = JsonRpcErrorResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::Number(2.into()),
|
||||
error: JsonRpcError {
|
||||
code: -32600,
|
||||
message: "Invalid Request".to_string(),
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
let result = JsonRpcResult::Error(error_response);
|
||||
let converted = result.into_result();
|
||||
assert!(converted.is_err());
|
||||
|
||||
match converted {
|
||||
Err(TransportError::JsonRpcError(error)) => {
|
||||
assert_eq!(error.code, -32600);
|
||||
assert_eq!(error.message, "Invalid Request");
|
||||
}
|
||||
_ => panic!("Expected JsonRpcError"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test transport state enum.
|
||||
#[test]
|
||||
fn test_transport_state() {
|
||||
assert_eq!(TransportState::Disconnected, TransportState::Disconnected);
|
||||
assert_ne!(TransportState::Connected, TransportState::Disconnected);
|
||||
|
||||
let state = TransportState::Connecting;
|
||||
assert!(matches!(state, TransportState::Connecting));
|
||||
}
|
||||
|
||||
// Note: SSE event extraction is tested in http.rs unit tests
|
||||
Reference in New Issue
Block a user