sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.0] - 2025-11-10
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
#### Removed Deprecated Event Variants
|
||||
|
||||
The `MessagePartAdded` event variant has been removed from the `Event` enum. This variant was part of the old streaming system and has been replaced by the new ACP-style `SessionUpdate` event.
|
||||
|
||||
**Migration Guide:**
|
||||
|
||||
If you were using `MessagePartAdded`, you should migrate to using `SessionUpdate` instead:
|
||||
|
||||
**Old code:**
|
||||
```rust
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
// Handle message part
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**New code:**
|
||||
```rust
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { message_id, content, .. } => {
|
||||
// Handle user message content
|
||||
}
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
// Handle agent message content
|
||||
}
|
||||
SessionUpdate::AgentThoughtChunk { message_id, content, .. } => {
|
||||
// Handle agent thinking/reasoning
|
||||
}
|
||||
SessionUpdate::ToolCall { message_id, tool_call, .. } => {
|
||||
// Handle tool call initiated
|
||||
}
|
||||
SessionUpdate::ToolCallUpdate { message_id, tool_call_id, tool_call, .. } => {
|
||||
// Handle tool call updates
|
||||
}
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
1. `SessionUpdate` uses typed `ContentBlock` instead of generic `MessagePart`
|
||||
2. Updates are categorized by type (user/agent/thought/tool)
|
||||
3. Better separation of concerns for streaming content
|
||||
4. Aligns with Agent-Client Protocol (ACP) standards
|
||||
|
||||
**What This Means:**
|
||||
|
||||
- The protocol now uses a unified streaming model via `SessionUpdate`
|
||||
- Better alignment with ACP specification
|
||||
- Clearer separation between message lifecycle events and streaming updates
|
||||
- More structured content representation with `ContentBlock`
|
||||
|
||||
### Added
|
||||
|
||||
- `SessionUpdate` event variant with ACP-style streaming updates
|
||||
- `SessionUpdate` enum with variants:
|
||||
- `UserMessageChunk`: User message content streaming
|
||||
- `AgentMessageChunk`: Agent message content streaming
|
||||
- `AgentThoughtChunk`: Agent reasoning/thinking streaming
|
||||
- `ToolCall`: Tool call initiated
|
||||
- `ToolCallUpdate`: Tool call status/content updates
|
||||
- `ContentBlock` enum for structured content representation
|
||||
- `ToolCall` type with status tracking and metadata
|
||||
|
||||
### Changed
|
||||
|
||||
- Streaming events now use `SessionUpdate` instead of `MessagePartAdded`
|
||||
- Version bumped from 0.1.0 to 0.2.0 (breaking change)
|
||||
|
||||
## [0.1.0] - 2025-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- Initial protocol definition
|
||||
- `Event` enum with session, message, connector, and system events
|
||||
- `Session` and `SessionMetadata` types
|
||||
- `Message`, `MessageMetadata`, `MessageRole`, `MessageStatus` types
|
||||
- `MessagePart` enum for message content
|
||||
- OpenCode adapter for translating OpenCode events to Dirigent protocol
|
||||
- REST adapter for converting REST API responses
|
||||
- Comprehensive test suite
|
||||
@@ -0,0 +1,268 @@
|
||||
# dirigent_protocol
|
||||
|
||||
**Version:** 0.2.0
|
||||
**Status:** Active Development
|
||||
|
||||
ACP/MCP-aligned protocol library for agent-client interactions.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **Package README**: [README.md](README.md) - Main documentation
|
||||
- **Streaming Model**: [docs/streaming_model.md](docs/streaming_model.md) - Detailed SessionUpdate guide
|
||||
- **Migration Guide**: [docs/migration_from_0.1.md](docs/migration_from_0.1.md) - Upgrading from 0.1.x
|
||||
- **Architecture Doc**: [../../docs/architecture/protocol.md](../../docs/architecture/protocol.md) - System design
|
||||
|
||||
## Purpose
|
||||
|
||||
This package provides the core event protocol for Dirigent, enabling:
|
||||
- Real-time streaming of agent interactions
|
||||
- Provider-agnostic event representation
|
||||
- Tool lifecycle management
|
||||
- Structured content representation
|
||||
|
||||
## Key Types
|
||||
|
||||
```rust
|
||||
use dirigent_protocol::{
|
||||
Event, // Top-level event enum
|
||||
SessionUpdate, // Streaming content updates
|
||||
ContentBlock, // Structured content (Text, ResourceLink)
|
||||
ToolCall, // Tool execution state
|
||||
ToolCallStatus, // Pending → Running → Completed/Error
|
||||
TurnCompleteTrigger, // How turn completion was detected
|
||||
};
|
||||
```
|
||||
|
||||
## Event Semantics: MessageCompleted vs TurnComplete vs SessionIdle
|
||||
|
||||
Understanding the distinction between these three events is critical for correct system behavior:
|
||||
|
||||
### MessageCompleted - "Metadata is ready"
|
||||
- **Purpose**: Informational - signals that message metadata exists
|
||||
- **Timing**: Emitted when message record is created, content may still be streaming
|
||||
- **Consumer action**: Update UI status indicators ("Assistant is typing" → "Complete")
|
||||
- **Example**: Show message timestamp, update message count
|
||||
|
||||
### TurnComplete - "All content received" (ACTIONABLE)
|
||||
- **Purpose**: **Primary finalization signal** - all content for this turn is complete
|
||||
- **Timing**: Emitted AFTER all content chunks, tool calls, and metadata updates
|
||||
- **Consumer action**: **Finalize storage, lock state, trigger post-processing**
|
||||
- **Example**: Write message to disk, mark as immutable, generate summaries
|
||||
|
||||
### SessionIdle - "No recent activity"
|
||||
- **Purpose**: Informational - indicates session is quiet
|
||||
- **Timing**: Emitted AFTER TurnComplete as final safety signal
|
||||
- **Consumer action**: Hide spinners, update activity indicators
|
||||
- **Example**: Remove "typing" animation, update last activity timestamp
|
||||
|
||||
### Event Ordering Guarantee
|
||||
|
||||
```text
|
||||
1. MessageStarted (message created)
|
||||
2. SessionUpdate::*Chunk (content streaming)
|
||||
3. SessionUpdate::ToolCall* (tool execution)
|
||||
4. MessageCompleted (metadata ready) ← UI: "Complete"
|
||||
5. TurnComplete ← FINALIZE HERE!
|
||||
6. SessionIdle ← UI: hide spinner
|
||||
```
|
||||
|
||||
### Consumer Behavior Table
|
||||
|
||||
| Consumer | MessageCompleted | TurnComplete | SessionIdle |
|
||||
|----------|------------------|--------------|-------------|
|
||||
| **Archivist** | Ignore | **Finalize and write** | Safety net |
|
||||
| **UI Cache** | Update status | **Lock state** | Hide spinner |
|
||||
| **Conductor Bridge** | - | **Flush response** | Fallback flush |
|
||||
|
||||
### TurnCompleteTrigger Variants
|
||||
|
||||
The `TurnCompleteTrigger` enum indicates **how** the system determined completion:
|
||||
|
||||
- **`ExplicitSignal`**: Upstream provider sent explicit completion (e.g., OpenCode session.idle)
|
||||
- **`ResponseReceived`**: JSON-RPC response received (ACP stdio - response is last message)
|
||||
- **`OperationsComplete`**: All tracked operations finished (e.g., pending tool calls resolved)
|
||||
- **`IdleTimeout { duration_ms }`**: Timeout-based detection (fallback mechanism)
|
||||
|
||||
**For most consumers**, treat all triggers the same - the turn is complete. The trigger type is primarily for debugging and observability.
|
||||
|
||||
## Usage Pattern
|
||||
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
|
||||
|
||||
fn handle_event(event: Event) {
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
if let ContentBlock::Text { text } = content {
|
||||
println!("Agent: {}", text);
|
||||
}
|
||||
}
|
||||
SessionUpdate::ToolCall { tool_call, .. } => {
|
||||
println!("Tool: {}", tool_call.tool_name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
dirigent_protocol/
|
||||
├── src/
|
||||
│ ├── types/ # Core types
|
||||
│ │ ├── content.rs # ContentBlock definitions
|
||||
│ │ ├── updates.rs # SessionUpdate variants
|
||||
│ │ ├── tool.rs # ToolCall, ToolCallStatus
|
||||
│ │ └── meta.rs # Provider metadata
|
||||
│ ├── session.rs # Session types
|
||||
│ ├── conversation.rs # Message types
|
||||
│ ├── events.rs # Event enum
|
||||
│ └── adapters/ # Provider adapters
|
||||
│ ├── opencode.rs # OpenCode translation
|
||||
│ └── rest.rs # REST translation
|
||||
├── docs/ # Detailed documentation
|
||||
├── examples/ # Usage examples
|
||||
└── tests/ # Integration tests
|
||||
```
|
||||
|
||||
## Version 0.2.0 Changes
|
||||
|
||||
**Breaking:**
|
||||
- Removed `Event::MessagePartAdded`
|
||||
|
||||
**New:**
|
||||
- `SessionUpdate` event system (ACP-style)
|
||||
- `ContentBlock` types (MCP-compatible)
|
||||
- `ToolCall` with lifecycle tracking
|
||||
- Provider metadata via `_meta`
|
||||
|
||||
See [docs/migration_from_0.1.md](docs/migration_from_0.1.md) for migration guide.
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
cargo test --package dirigent_protocol
|
||||
```
|
||||
|
||||
### Checking Code
|
||||
```bash
|
||||
cargo check --package dirigent_protocol
|
||||
```
|
||||
|
||||
### Running Examples
|
||||
```bash
|
||||
cargo run --package dirigent_protocol --example session_metadata_demo
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
This package is used by:
|
||||
- **api** package: Server functions consume protocol events
|
||||
- **web** package: UI renders protocol events
|
||||
- **dirigent_core** (future): Runtime emits protocol events
|
||||
|
||||
## Adapters
|
||||
|
||||
The adapter system translates provider-specific events to Dirigent Protocol:
|
||||
|
||||
- **OpenCodeAdapter**: Translates OpenCode.ai events
|
||||
- **RESTAdapter**: Converts REST API responses
|
||||
|
||||
Adapters preserve provider metadata in the `_meta` field for debugging and traceability.
|
||||
|
||||
## Current Scope
|
||||
|
||||
**Phase 1 (Implemented):**
|
||||
- User/Agent/Thought message streaming
|
||||
- Tool lifecycle (Pending → Running → Completed/Error)
|
||||
- Text and ResourceLink content types
|
||||
- Provider metadata support
|
||||
|
||||
**Deferred to Future Phases:**
|
||||
- Plans and mode switching
|
||||
- Permission system
|
||||
- Embedded resources (full content)
|
||||
- Rich media (images, audio)
|
||||
- Multi-agent communication
|
||||
|
||||
See [../../docs/building/03_acp_prep/04_first_order_refactor.md](../../docs/building/03_acp_prep/04_first_order_refactor.md) for the full plan.
|
||||
|
||||
## Standards Alignment
|
||||
|
||||
**Agent-Client Protocol (ACP):**
|
||||
- Session-centric streaming
|
||||
- Separate content types (user/agent/thought)
|
||||
- Tool status tracking
|
||||
|
||||
**Model Context Protocol (MCP):**
|
||||
- ContentBlock structure
|
||||
- Resource links
|
||||
- Extensible content types
|
||||
|
||||
Differences from standards are documented in [../../docs/architecture/protocol.md](../../docs/architecture/protocol.md).
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Timeout-Based Event Waiting (FORBIDDEN)
|
||||
|
||||
**Never use timeout-based waiting to receive events that should be available immediately.**
|
||||
|
||||
```rust
|
||||
// ❌ BAD - Race condition waiting for event
|
||||
async fn wait_for_metadata_event(
|
||||
events: &mut broadcast::Receiver<Event>,
|
||||
timeout: Duration,
|
||||
) -> Option<SessionMetadataReceived> {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
match tokio::time::timeout(Duration::from_millis(100), events.recv()).await {
|
||||
Ok(Ok(Event::SessionMetadataReceived { .. })) => return Some(...),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
1. **Race condition**: The event may have been emitted before the receiver subscribed
|
||||
2. **Arbitrary delays**: 500ms waits add latency for no good reason
|
||||
3. **Silent failures**: Timeout expiring doesn't indicate the real problem
|
||||
4. **Fragile**: Works "most of the time" but fails under load or timing variations
|
||||
|
||||
**Instead, pass data directly:**
|
||||
```rust
|
||||
// ✅ GOOD - Extract data from existing events
|
||||
async fn create_session_in_connector(...) -> Result<(String, Option<Models>, Option<Modes>), String> {
|
||||
// The SessionCreated event already contains models/modes
|
||||
match event {
|
||||
Event::SessionCreated { session, .. } => {
|
||||
Ok((session.id, session.models, session.modes))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rule**: If you find yourself writing `timeout(Duration::from_millis(N), events.recv())` to wait for an event that "should" arrive, the architecture is wrong. Refactor to pass data directly through return values or existing event payloads.
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding features:
|
||||
1. Update type definitions in `src/types/`
|
||||
2. Add comprehensive tests
|
||||
3. Update documentation in `docs/`
|
||||
4. Add examples if applicable
|
||||
5. Update CHANGELOG.md
|
||||
|
||||
## See Also
|
||||
|
||||
- Main project: [../../CLAUDE.md](../../CLAUDE.md)
|
||||
- Architecture docs: [../../docs/architecture/](../../docs/architecture/)
|
||||
- Building docs: [../../docs/building/](../../docs/building/)
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "dirigent_protocol"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
opencode_client = { workspace = true, optional = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.18", features = ["js", "serde", "v4", "v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt", "sync"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
adapters = ["dep:opencode_client"]
|
||||
|
||||
[[test]]
|
||||
name = "opencode_session_update_tests"
|
||||
required-features = ["adapters"]
|
||||
|
||||
[[test]]
|
||||
name = "protocol_tests"
|
||||
required-features = ["adapters"]
|
||||
|
||||
[[test]]
|
||||
name = "session_list_tests"
|
||||
required-features = ["adapters"]
|
||||
|
||||
[[test]]
|
||||
name = "deduplication_tests"
|
||||
required-features = ["adapters"]
|
||||
@@ -0,0 +1,352 @@
|
||||
# Dirigent Protocol
|
||||
|
||||
**Version:** 0.2.0
|
||||
|
||||
A Rust protocol library for agent-client interactions, aligned with **Agent-Client Protocol (ACP)** and **Model Context Protocol (MCP)** standards.
|
||||
|
||||
## Overview
|
||||
|
||||
The Dirigent Protocol provides a structured, streaming-first event model for real-time agent interactions. It's designed to support multi-agent orchestration, tool execution, and rich content streaming while maintaining compatibility with standard protocols.
|
||||
|
||||
## Features
|
||||
|
||||
- **ACP-Style Streaming**: Real-time content updates via `SessionUpdate` events
|
||||
- **MCP-Compatible Content**: Structured `ContentBlock` representation
|
||||
- **Tool Lifecycle Management**: Complete tool call tracking from initiation to completion
|
||||
- **Provider Agnostic**: Adapter system for integrating different AI providers
|
||||
- **Type-Safe**: Strongly-typed Rust API with comprehensive serde support
|
||||
- **Extensible**: Provider metadata and extensibility hooks
|
||||
|
||||
## Quick Start
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
dirigent_protocol = "0.2"
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
|
||||
|
||||
fn handle_event(event: Event) {
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
if let ContentBlock::Text { text } = content {
|
||||
println!("Agent says: {}", text);
|
||||
}
|
||||
}
|
||||
SessionUpdate::ToolCall { tool_call, .. } => {
|
||||
println!("Tool called: {}", tool_call.tool_name);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::SessionCreated { session } => {
|
||||
println!("New session: {}", session.id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### SessionUpdate Event Model
|
||||
|
||||
The protocol uses `SessionUpdate` for all streaming content:
|
||||
|
||||
```rust
|
||||
pub enum SessionUpdate {
|
||||
UserMessageChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
AgentMessageChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
AgentThoughtChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
ToolCall { message_id: String, tool_call: ToolCall, _meta: Option<Meta> },
|
||||
ToolCallUpdate { message_id: String, tool_call_id: String, tool_call: ToolCall, _meta: Option<Meta> },
|
||||
}
|
||||
```
|
||||
|
||||
### ContentBlock Types
|
||||
|
||||
Structured content representation:
|
||||
|
||||
```rust
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
ResourceLink {
|
||||
uri: String,
|
||||
name: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Call Lifecycle
|
||||
|
||||
Complete tool execution tracking:
|
||||
|
||||
```rust
|
||||
pub struct ToolCall {
|
||||
pub id: ToolCallId,
|
||||
pub tool_name: String,
|
||||
pub status: ToolCallStatus, // Pending → Running → Completed/Error
|
||||
pub content: Vec<ContentBlock>,
|
||||
pub raw_input: Option<Value>,
|
||||
pub raw_output: Option<Value>,
|
||||
pub title: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Streaming Model](docs/streaming_model.md)** - Detailed guide to the SessionUpdate event system
|
||||
- **[Migration Guide](docs/migration_from_0.1.md)** - Upgrading from 0.1.x to 0.2.0
|
||||
- **[CHANGELOG.md](CHANGELOG.md)** - Version history and breaking changes
|
||||
- **[Examples](examples/)** - Working code examples
|
||||
|
||||
## Examples
|
||||
|
||||
### Streaming Text
|
||||
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
|
||||
|
||||
// Agent streaming response
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123".to_string(),
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello, world!".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Tool Execution
|
||||
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ToolCall, ToolCallStatus};
|
||||
|
||||
// Tool call initiated
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123".to_string(),
|
||||
update: SessionUpdate::ToolCall {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"command": "ls"})),
|
||||
raw_output: None,
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// Tool call completed
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123".to_string(),
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "file1.txt\nfile2.txt".to_string(),
|
||||
},
|
||||
],
|
||||
raw_input: Some(json!({"command": "ls"})),
|
||||
raw_output: Some(json!({"exit_code": 0})),
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Agent Thinking
|
||||
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
|
||||
|
||||
// Agent internal reasoning
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123".to_string(),
|
||||
update: SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Analyzing the user's request...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Adapters
|
||||
|
||||
The protocol includes adapter modules for translating provider-specific events:
|
||||
|
||||
- **OpenCode Adapter**: Converts OpenCode.ai events to Dirigent Protocol
|
||||
- **REST Adapter**: Translates REST API responses
|
||||
|
||||
### Using an Adapter
|
||||
|
||||
```rust
|
||||
use dirigent_protocol::adapters::opencode::OpenCodeAdapter;
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let dirigent_events = adapter.translate_event(opencode_event);
|
||||
```
|
||||
|
||||
## Version History
|
||||
|
||||
### 0.2.0 (Current)
|
||||
|
||||
**Breaking Changes:**
|
||||
- Removed `Event::MessagePartAdded` (replaced with `SessionUpdate`)
|
||||
|
||||
**New Features:**
|
||||
- ACP-style `SessionUpdate` event system
|
||||
- MCP-compatible `ContentBlock` types
|
||||
- Structured `ToolCall` with lifecycle tracking
|
||||
- Provider metadata via `_meta` field
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for details.
|
||||
|
||||
### 0.1.0
|
||||
|
||||
Initial release with basic event types and adapters.
|
||||
|
||||
## Migration from 0.1.x
|
||||
|
||||
If you're upgrading from version 0.1.x, see the [Migration Guide](docs/migration_from_0.1.md) for detailed instructions and examples.
|
||||
|
||||
**Quick Summary:**
|
||||
- Replace `Event::MessagePartAdded` with `Event::SessionUpdate`
|
||||
- Use `SessionUpdate` variants instead of `MessagePart`
|
||||
- Handle tool lifecycle with `ToolCall` and `ToolCallUpdate`
|
||||
- Access `ContentBlock::Text { text }` instead of `MessagePart::Text { content }`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
dirigent_protocol/
|
||||
├── src/
|
||||
│ ├── types/ # Core types
|
||||
│ │ ├── content.rs # ContentBlock definitions
|
||||
│ │ ├── updates.rs # SessionUpdate variants
|
||||
│ │ ├── tool.rs # Tool call types
|
||||
│ │ └── meta.rs # Provider metadata
|
||||
│ ├── session.rs # Session types
|
||||
│ ├── conversation.rs # Message types
|
||||
│ ├── events.rs # Event enum
|
||||
│ └── adapters/ # Provider adapters
|
||||
│ ├── opencode.rs # OpenCode adapter
|
||||
│ └── rest.rs # REST adapter
|
||||
├── docs/ # Documentation
|
||||
├── examples/ # Code examples
|
||||
└── tests/ # Integration tests
|
||||
```
|
||||
|
||||
## Event Types
|
||||
|
||||
The protocol defines several event categories:
|
||||
|
||||
### Session Events
|
||||
- `SessionCreated` - New session started
|
||||
- `SessionUpdated` - Session metadata changed
|
||||
- `SessionDeleted` - Session removed
|
||||
- `SessionsListed` - Available sessions returned
|
||||
|
||||
### Streaming Events
|
||||
- `SessionUpdate` - Real-time content/tool updates (see SessionUpdate variants above)
|
||||
|
||||
### Message Events
|
||||
- `MessageStarted` - Message creation initiated
|
||||
- `MessageCompleted` - Message finalized
|
||||
- `MessageDeleted` - Message removed
|
||||
|
||||
### System Events
|
||||
- `ConnectorStateChanged` - Connector status changed
|
||||
- `Error` - Error occurred
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Consumers
|
||||
|
||||
1. **Use SessionUpdate for streaming**: The new event model provides granular updates
|
||||
2. **Track tools by ID**: Maintain a HashMap of tool calls keyed by `tool_call_id`
|
||||
3. **Replace tool state on update**: `ToolCallUpdate` sends complete state, not deltas
|
||||
4. **Distinguish thoughts from messages**: Render `AgentThoughtChunk` differently (e.g., collapsible sections)
|
||||
5. **Handle optional fields**: Provider metadata (`_meta`) may not always be present
|
||||
|
||||
### For Adapters
|
||||
|
||||
1. **Preserve provider info**: Store original IDs in `_meta.provider` for debugging
|
||||
2. **Send complete tool state**: Include all tool call fields in updates
|
||||
3. **Use appropriate chunk types**: Choose User/Agent/Thought for correct semantics
|
||||
4. **Keep metadata minimal**: Avoid large payloads in `_meta` (use excerpts)
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Run with output:
|
||||
|
||||
```bash
|
||||
cargo test -- --nocapture
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please ensure:
|
||||
- All tests pass
|
||||
- New features include tests
|
||||
- Documentation is updated
|
||||
- Code follows Rust conventions
|
||||
|
||||
## License
|
||||
|
||||
[License information here]
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **dirigent_core** - Multi-agent orchestration runtime
|
||||
- **opencode_client** - OpenCode.ai HTTP client library
|
||||
- **dirigent_archive** - Session persistence
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues:
|
||||
- Check the [documentation](docs/)
|
||||
- Review [examples](examples/)
|
||||
- Open an issue on the repository
|
||||
|
||||
## Standards Alignment
|
||||
|
||||
This protocol is designed to align with:
|
||||
- **Agent-Client Protocol (ACP)** - Streaming model and event types
|
||||
- **Model Context Protocol (MCP)** - Content block structure
|
||||
|
||||
Differences from standards are documented for compatibility and future convergence.
|
||||
@@ -0,0 +1,620 @@
|
||||
# Migration Guide: Dirigent Protocol 0.1.x → 0.2.0
|
||||
|
||||
## Overview
|
||||
|
||||
Version 0.2.0 introduces a **new ACP-style streaming model** while maintaining backward compatibility with most existing code. This guide will help you migrate to the new `SessionUpdate` event system.
|
||||
|
||||
## Breaking Changes Summary
|
||||
|
||||
### Removed: Event::MessagePartAdded
|
||||
|
||||
The `MessagePartAdded` event variant has been **removed** from the `Event` enum. This was the primary breaking change in 0.2.0.
|
||||
|
||||
**What was removed:**
|
||||
```rust
|
||||
// This no longer exists in 0.2.0
|
||||
Event::MessagePartAdded {
|
||||
session_id: String,
|
||||
message_id: String,
|
||||
part: MessagePart,
|
||||
}
|
||||
```
|
||||
|
||||
**Replaced with:**
|
||||
```rust
|
||||
// New in 0.2.0
|
||||
Event::SessionUpdate {
|
||||
session_id: String,
|
||||
update: SessionUpdate,
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Patterns
|
||||
|
||||
### Pattern 1: Basic Text Streaming
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, MessagePart};
|
||||
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Text { content, .. } => {
|
||||
println!("Text from {}: {}", message_id, content);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
|
||||
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { message_id, content, .. } |
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
println!("Text from {}: {}", message_id, text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- Use `SessionUpdate` instead of `MessagePartAdded`
|
||||
- Separate `UserMessageChunk` and `AgentMessageChunk` variants
|
||||
- `ContentBlock::Text { text }` instead of `MessagePart::Text { content }`
|
||||
- Field renamed: `content` → `text`
|
||||
|
||||
### Pattern 2: Thinking/Reasoning Content
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Thinking { content, .. } => {
|
||||
println!("Agent thinking: {}", content);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::AgentThoughtChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
println!("Agent thinking: {}", text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- `MessagePart::Thinking` → `SessionUpdate::AgentThoughtChunk`
|
||||
- Content wrapped in `ContentBlock::Text`
|
||||
|
||||
### Pattern 3: Tool Calls
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Tool {
|
||||
tool_name,
|
||||
tool_call_id,
|
||||
status,
|
||||
input,
|
||||
output,
|
||||
..
|
||||
} => {
|
||||
println!("Tool {}: {:?}", tool_name, status);
|
||||
if let Some(out) = output {
|
||||
println!("Output: {}", out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
use dirigent_protocol::ToolCallStatus;
|
||||
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
// Initial tool call
|
||||
SessionUpdate::ToolCall { message_id, tool_call, .. } => {
|
||||
println!("Tool {}: {:?}", tool_call.tool_name, tool_call.status);
|
||||
}
|
||||
|
||||
// Tool call updates (progress, completion, errors)
|
||||
SessionUpdate::ToolCallUpdate { message_id, tool_call_id, tool_call, .. } => {
|
||||
println!("Tool {} updated: {:?}", tool_call.tool_name, tool_call.status);
|
||||
|
||||
// Output is now in content blocks
|
||||
for content in &tool_call.content {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
println!("Output: {}", text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if tool_call.status == ToolCallStatus::Error {
|
||||
if let Some(error) = &tool_call.error {
|
||||
println!("Error: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- Tool lifecycle split into `ToolCall` (initial) and `ToolCallUpdate` (updates)
|
||||
- Tool output now in `tool_call.content: Vec<ContentBlock>`
|
||||
- Structured `ToolCall` type with multiple fields
|
||||
- Explicit `ToolCallStatus` enum (Pending/Running/Completed/Error)
|
||||
- Error information in dedicated `error` field
|
||||
|
||||
### Pattern 4: File References
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::File { path, name, mime_type, .. } => {
|
||||
println!("File reference: {} ({})", name.unwrap_or_default(), path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::ResourceLink { uri, name, mime_type } => {
|
||||
println!("Resource: {} ({})",
|
||||
name.as_deref().unwrap_or("unnamed"),
|
||||
uri
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- `MessagePart::File` → `ContentBlock::ResourceLink`
|
||||
- Field renamed: `path` → `uri` (more generic)
|
||||
- Can appear in any message chunk type
|
||||
|
||||
## Complete Migration Example
|
||||
|
||||
Here's a complete before/after example showing a typical event handler:
|
||||
|
||||
### Before (0.1.x)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, MessagePart};
|
||||
|
||||
fn handle_event(event: Event) {
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Text { content, .. } => {
|
||||
append_text(&message_id, &content);
|
||||
}
|
||||
MessagePart::Thinking { content, .. } => {
|
||||
show_thinking(&message_id, &content);
|
||||
}
|
||||
MessagePart::Tool { tool_name, tool_call_id, status, output, .. } => {
|
||||
update_tool_display(&tool_call_id, &tool_name, status, output.as_deref());
|
||||
}
|
||||
MessagePart::File { path, name, .. } => {
|
||||
add_file_reference(&message_id, &path, name.as_deref());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::MessageStarted { session_id, message_id, .. } => {
|
||||
create_message_container(&message_id);
|
||||
}
|
||||
Event::MessageCompleted { session_id, message_id, .. } => {
|
||||
finalize_message(&message_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After (0.2.0)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock, ToolCallStatus};
|
||||
|
||||
fn handle_event(event: Event) {
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { message_id, content, .. } |
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
append_text(&message_id, &text);
|
||||
}
|
||||
ContentBlock::ResourceLink { uri, name, .. } => {
|
||||
add_file_reference(&message_id, &uri, name.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SessionUpdate::AgentThoughtChunk { message_id, content, .. } => {
|
||||
if let ContentBlock::Text { text } = content {
|
||||
show_thinking(&message_id, &text);
|
||||
}
|
||||
}
|
||||
|
||||
SessionUpdate::ToolCall { message_id, tool_call, .. } => {
|
||||
create_tool_display(
|
||||
&tool_call.id,
|
||||
&tool_call.tool_name,
|
||||
tool_call.status,
|
||||
);
|
||||
}
|
||||
|
||||
SessionUpdate::ToolCallUpdate { tool_call_id, tool_call, .. } => {
|
||||
// Extract output text from content blocks
|
||||
let output_text = tool_call.content.iter()
|
||||
.filter_map(|c| match c {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
update_tool_display(
|
||||
&tool_call_id,
|
||||
&tool_call.tool_name,
|
||||
tool_call.status,
|
||||
if output_text.is_empty() { None } else { Some(&output_text) },
|
||||
);
|
||||
|
||||
// Handle errors
|
||||
if tool_call.status == ToolCallStatus::Error {
|
||||
if let Some(error) = &tool_call.error {
|
||||
show_tool_error(&tool_call_id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These events remain unchanged
|
||||
Event::MessageStarted { session_id, message_id, .. } => {
|
||||
create_message_container(&message_id);
|
||||
}
|
||||
Event::MessageCompleted { session_id, message_id, .. } => {
|
||||
finalize_message(&message_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI State Management Changes
|
||||
|
||||
### Before: Simple Append Model
|
||||
|
||||
```rust
|
||||
struct MessageState {
|
||||
id: String,
|
||||
text: String,
|
||||
thinking: String,
|
||||
tools: Vec<ToolDisplay>,
|
||||
}
|
||||
|
||||
// On MessagePartAdded with Text:
|
||||
message.text.push_str(&content);
|
||||
```
|
||||
|
||||
### After: ContentBlock Streaming
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct MessageState {
|
||||
id: String,
|
||||
content_blocks: Vec<ContentBlock>,
|
||||
thoughts: Vec<ContentBlock>,
|
||||
tools: HashMap<String, ToolCall>, // Keyed by tool_call_id
|
||||
}
|
||||
|
||||
// On AgentMessageChunk:
|
||||
message.content_blocks.push(content);
|
||||
|
||||
// On AgentThoughtChunk:
|
||||
message.thoughts.push(content);
|
||||
|
||||
// On ToolCall:
|
||||
message.tools.insert(tool_call.id.clone(), tool_call);
|
||||
|
||||
// On ToolCallUpdate:
|
||||
message.tools.insert(tool_call_id.clone(), tool_call); // Replace, not merge
|
||||
```
|
||||
|
||||
**Key Insight:** ToolCallUpdate sends the **complete tool state**, not a delta. Always replace the existing tool call entry.
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
The following events and types are **unchanged** and require no migration:
|
||||
|
||||
### Session Events
|
||||
- `Event::SessionCreated`
|
||||
- `Event::SessionUpdated`
|
||||
- `Event::SessionDeleted`
|
||||
- `Event::SessionsListed`
|
||||
|
||||
### Message Lifecycle Events
|
||||
- `Event::MessageStarted`
|
||||
- `Event::MessageCompleted`
|
||||
- `Event::MessageDeleted`
|
||||
|
||||
### Connector Events
|
||||
- `Event::ConnectorStateChanged`
|
||||
|
||||
### Types
|
||||
- `Session`
|
||||
- `SessionMetadata` (extended with optional fields, but backward compatible)
|
||||
- `Message`
|
||||
- `MessageMetadata`
|
||||
- `MessageRole`
|
||||
- `MessageStatus`
|
||||
- `MessagePart` (still used for completed messages)
|
||||
|
||||
## Common Migration Issues
|
||||
|
||||
### Issue 1: Pattern Matching Exhaustiveness
|
||||
|
||||
**Problem:** Compiler errors about non-exhaustive patterns after removing `MessagePartAdded`.
|
||||
|
||||
**Solution:** Remove any match arms for `MessagePartAdded` and add `SessionUpdate` handling.
|
||||
|
||||
### Issue 2: Field Name Mismatches
|
||||
|
||||
**Problem:** `MessagePart::Text { content }` vs `ContentBlock::Text { text }`
|
||||
|
||||
**Solution:** Update field access from `content` to `text`.
|
||||
|
||||
### Issue 3: Tool Call State Management
|
||||
|
||||
**Problem:** Treating `ToolCallUpdate` as a delta instead of complete state.
|
||||
|
||||
**Solution:** Replace the entire tool call entry when receiving `ToolCallUpdate`, don't try to merge.
|
||||
|
||||
**Incorrect:**
|
||||
```rust
|
||||
// DON'T do this
|
||||
if let Some(existing) = tools.get_mut(&tool_call_id) {
|
||||
existing.content.extend(tool_call.content); // Wrong!
|
||||
existing.status = tool_call.status;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```rust
|
||||
// DO this
|
||||
tools.insert(tool_call_id.clone(), tool_call); // Replace completely
|
||||
```
|
||||
|
||||
### Issue 4: Missing _meta Fields
|
||||
|
||||
**Problem:** Trying to access `_meta` that might be `None`.
|
||||
|
||||
**Solution:** Always use `Option` handling or provide defaults.
|
||||
|
||||
```rust
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk { _meta, .. } => {
|
||||
if let Some(meta) = _meta {
|
||||
if let Some(provider) = &meta.provider {
|
||||
println!("Provider: {}", provider.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Your Migration
|
||||
|
||||
### Test Checklist
|
||||
|
||||
- [ ] Text streaming displays correctly
|
||||
- [ ] Agent thoughts appear in designated section
|
||||
- [ ] User messages are distinguished from agent messages
|
||||
- [ ] Tool calls show initial pending state
|
||||
- [ ] Tool calls update with running status
|
||||
- [ ] Tool calls complete successfully
|
||||
- [ ] Tool errors display with error messages
|
||||
- [ ] File references render as links
|
||||
- [ ] Multiple content chunks accumulate properly
|
||||
- [ ] Tool call state replaces (not merges) on update
|
||||
|
||||
### Migration Test Example
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_migration_text_streaming() {
|
||||
let event = Event::SessionUpdate {
|
||||
session_id: "test_session".to_string(),
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Your handler should process this correctly
|
||||
handle_event(event);
|
||||
|
||||
// Assert expected state changes
|
||||
assert_eq!(get_message_text("msg_1"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_tool_lifecycle() {
|
||||
use dirigent_protocol::ToolCallStatus;
|
||||
|
||||
// 1. Initial tool call
|
||||
handle_event(Event::SessionUpdate {
|
||||
session_id: "test".to_string(),
|
||||
update: SessionUpdate::ToolCall {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
});
|
||||
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Pending);
|
||||
|
||||
// 2. Tool starts running
|
||||
handle_event(Event::SessionUpdate {
|
||||
session_id: "test".to_string(),
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
});
|
||||
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Running);
|
||||
|
||||
// 3. Tool completes with output
|
||||
handle_event(Event::SessionUpdate {
|
||||
session_id: "test".to_string(),
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "Done!".to_string(),
|
||||
},
|
||||
],
|
||||
raw_input: None,
|
||||
raw_output: Some(serde_json::json!({"exit_code": 0})),
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
});
|
||||
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Completed);
|
||||
assert_eq!(get_tool_output("call_1"), "Done!");
|
||||
}
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
||||
1. Check the [streaming_model.md](streaming_model.md) documentation for detailed API information
|
||||
2. Review the [examples/](../examples/) directory for working code samples
|
||||
3. Examine the [CHANGELOG.md](../CHANGELOG.md) for version-specific details
|
||||
4. Look at the protocol source code tests for reference implementations
|
||||
|
||||
## Quick Reference: Event Type Mapping
|
||||
|
||||
| 0.1.x Event | 0.2.0 Event | Notes |
|
||||
|-------------|-------------|-------|
|
||||
| `MessagePartAdded` with `Text` | `SessionUpdate::AgentMessageChunk` or `UserMessageChunk` | Split by role |
|
||||
| `MessagePartAdded` with `Thinking` | `SessionUpdate::AgentThoughtChunk` | Dedicated type |
|
||||
| `MessagePartAdded` with `Tool` | `SessionUpdate::ToolCall` + `ToolCallUpdate` | Split lifecycle |
|
||||
| `MessagePartAdded` with `File` | `ContentBlock::ResourceLink` in chunks | Different structure |
|
||||
| All other events | Unchanged | No migration needed |
|
||||
|
||||
## Summary
|
||||
|
||||
The 0.2.0 migration primarily involves:
|
||||
|
||||
1. Replacing `Event::MessagePartAdded` pattern matching with `Event::SessionUpdate`
|
||||
2. Using `SessionUpdate` variants instead of `MessagePart` variants
|
||||
3. Accessing `ContentBlock::Text { text }` instead of `MessagePart::Text { content }`
|
||||
4. Handling tool lifecycle with separate `ToolCall` and `ToolCallUpdate` events
|
||||
5. Managing tool state as complete snapshots, not deltas
|
||||
|
||||
The new model provides better structure, clearer semantics, and improved alignment with ACP standards while maintaining most of your existing code.
|
||||
@@ -0,0 +1,476 @@
|
||||
# Dirigent Protocol Streaming Model
|
||||
|
||||
## Overview
|
||||
|
||||
The Dirigent Protocol uses an **ACP-style streaming model** built around `SessionUpdate` events. This model provides granular, real-time updates during agent interactions, enabling responsive UIs and structured content representation.
|
||||
|
||||
Version: 0.2.0
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### SessionUpdate Events
|
||||
|
||||
All streaming content is delivered through `SessionUpdate` variants wrapped in `Event::SessionUpdate`:
|
||||
|
||||
```rust
|
||||
pub enum Event {
|
||||
// ... other events
|
||||
SessionUpdate {
|
||||
session_id: String,
|
||||
update: SessionUpdate,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `SessionUpdate` enum contains five variants for different types of streaming updates:
|
||||
|
||||
```rust
|
||||
pub enum SessionUpdate {
|
||||
UserMessageChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
AgentMessageChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
AgentThoughtChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
ToolCall { message_id: String, tool_call: ToolCall, _meta: Option<Meta> },
|
||||
ToolCallUpdate { message_id: String, tool_call_id: String, tool_call: ToolCall, _meta: Option<Meta> },
|
||||
}
|
||||
```
|
||||
|
||||
### ContentBlock Types
|
||||
|
||||
Content is represented using structured `ContentBlock` variants:
|
||||
|
||||
```rust
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
ResourceLink {
|
||||
uri: String,
|
||||
name: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
// Future: Resource, Image, Audio (marked as out-of-scope for phase 1)
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- `Text` is the primary content type for all textual output
|
||||
- `ResourceLink` represents file references without embedding full content
|
||||
- Future expansions will include embedded resources, images, and audio
|
||||
|
||||
### Provider Metadata (_meta)
|
||||
|
||||
All `SessionUpdate` variants support optional `_meta` fields for provider-specific information:
|
||||
|
||||
```rust
|
||||
pub struct Meta {
|
||||
pub provider: Option<ProviderMeta>,
|
||||
pub extra: HashMap<String, Value>, // Arbitrary additional fields
|
||||
}
|
||||
|
||||
pub struct ProviderMeta {
|
||||
pub name: String, // e.g., "opencode", "anthropic"
|
||||
pub original_ids: Option<HashMap<String, String>>, // Original provider IDs
|
||||
pub raw_excerpt: Option<Value>, // Minimal raw payload for debugging
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
- Adapters populate `_meta` to preserve provider-specific information
|
||||
- Consumers can use this for debugging, telemetry, or provider-specific features
|
||||
- The `extra` map allows arbitrary fields for forward compatibility
|
||||
|
||||
## SessionUpdate Variants
|
||||
|
||||
### 1. UserMessageChunk
|
||||
|
||||
Represents streaming chunks of user message content.
|
||||
|
||||
```rust
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_abc123".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "What's the capital of France?".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Streaming user input being typed
|
||||
- Echo of user input from server
|
||||
- Multi-part user messages being assembled
|
||||
|
||||
### 2. AgentMessageChunk
|
||||
|
||||
Represents streaming chunks of agent response content.
|
||||
|
||||
```rust
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_def456".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "The capital of France is ".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "opencode".to_string(),
|
||||
original_ids: Some(HashMap::from([
|
||||
("message_id".to_string(), "original_123".to_string()),
|
||||
])),
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Agent's response text being generated
|
||||
- Final answer content
|
||||
- Any visible agent output
|
||||
|
||||
**Key distinction from AgentThoughtChunk:**
|
||||
- `AgentMessageChunk`: Visible output intended for the user
|
||||
- `AgentThoughtChunk`: Internal reasoning, typically hidden or collapsible
|
||||
|
||||
### 3. AgentThoughtChunk
|
||||
|
||||
Represents streaming chunks of agent internal reasoning (thinking, planning).
|
||||
|
||||
```rust
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_ghi789".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "I need to look up Paris in my knowledge base...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Agent's internal reasoning process
|
||||
- "Chain of thought" content
|
||||
- Planning or decision-making process
|
||||
- Content typically displayed in collapsible sections
|
||||
|
||||
**UI Conventions:**
|
||||
- Often hidden by default or shown in a separate "Thinking" section
|
||||
- May be styled differently (e.g., italics, muted colors)
|
||||
- Can be collapsed to save screen space
|
||||
|
||||
### 4. ToolCall
|
||||
|
||||
Represents the initiation or current state of a tool call.
|
||||
|
||||
```rust
|
||||
SessionUpdate::ToolCall {
|
||||
message_id: "msg_jkl012".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_xyz789".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({
|
||||
"file_path": "/path/to/file.txt"
|
||||
})),
|
||||
raw_output: None,
|
||||
title: Some("Read file.txt".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Tool call is first initiated
|
||||
- Sending a snapshot of current tool state
|
||||
- Re-sending full tool state after reconnection
|
||||
|
||||
**ToolCallStatus Lifecycle:**
|
||||
- `Pending` → Tool call created but not yet executing
|
||||
- `Running` → Tool call actively executing
|
||||
- `Completed` → Tool call finished successfully
|
||||
- `Error` → Tool call failed
|
||||
|
||||
### 5. ToolCallUpdate
|
||||
|
||||
Represents an update to an existing tool call (status change, new content).
|
||||
|
||||
```rust
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_jkl012".to_string(),
|
||||
tool_call_id: "call_xyz789".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_xyz789".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "Reading file...".to_string(),
|
||||
},
|
||||
],
|
||||
raw_input: Some(json!({
|
||||
"file_path": "/path/to/file.txt"
|
||||
})),
|
||||
raw_output: None,
|
||||
title: Some("Read file.txt".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({
|
||||
"bytes_read": 1024
|
||||
})),
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Tool status changes (Pending → Running → Completed/Error)
|
||||
- New output content available
|
||||
- Progress updates
|
||||
- Error state reached
|
||||
|
||||
**Note:** The full `ToolCall` is sent each time, not a delta. Consumers should replace the previous tool call state with the new one.
|
||||
|
||||
## Tool Call Lifecycle
|
||||
|
||||
Understanding the tool call lifecycle is essential for proper UI implementation:
|
||||
|
||||
```rust
|
||||
// 1. Tool call initiated
|
||||
SessionUpdate::ToolCall {
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Tool starts executing
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tool produces output
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "Output line 1" },
|
||||
],
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 4a. Tool completes successfully
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "Output line 1" },
|
||||
ContentBlock::Text { text: "Done!" },
|
||||
],
|
||||
raw_output: Some(json!({"success": true})),
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. Or tool fails with error
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "Error output" },
|
||||
],
|
||||
error: Some("File not found".to_string()),
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI Implementation Guidelines:**
|
||||
- Track tool calls by `id` in a HashMap
|
||||
- On `ToolCall`: create new entry
|
||||
- On `ToolCallUpdate`: replace existing entry (not delta)
|
||||
- Display status with appropriate visual indicators
|
||||
- Show `content` blocks as streaming output
|
||||
- Display `error` when status is `Error`
|
||||
- Use `title` for tool call heading if available
|
||||
|
||||
## Typical Message Flow
|
||||
|
||||
Here's a complete example showing a typical agent interaction:
|
||||
|
||||
```rust
|
||||
// 1. User sends message
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_user_1",
|
||||
content: ContentBlock::Text {
|
||||
text: "Read and summarize config.toml",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 2. Agent starts thinking
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_agent_1",
|
||||
content: ContentBlock::Text {
|
||||
text: "I need to read the file first...",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 3. Agent initiates tool call
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::ToolCall {
|
||||
message_id: "msg_agent_1",
|
||||
tool_call: ToolCall {
|
||||
id: "call_read_1",
|
||||
tool_name: "read_file",
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"path": "config.toml"})),
|
||||
title: Some("Read config.toml"),
|
||||
// ...
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 4. Tool starts executing
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_agent_1",
|
||||
tool_call_id: "call_read_1",
|
||||
tool_call: ToolCall {
|
||||
id: "call_read_1",
|
||||
status: ToolCallStatus::Running,
|
||||
// ...
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 5. Tool completes
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_agent_1",
|
||||
tool_call_id: "call_read_1",
|
||||
tool_call: ToolCall {
|
||||
id: "call_read_1",
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "[port = 3000\n...]",
|
||||
},
|
||||
],
|
||||
raw_output: Some(json!({"bytes_read": 1024})),
|
||||
// ...
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 6. Agent responds with summary
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_1",
|
||||
content: ContentBlock::Text {
|
||||
text: "The config file sets the server port to 3000",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 7. More response chunks...
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_1",
|
||||
content: ContentBlock::Text {
|
||||
text: " and enables debug mode.",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Content vs MessagePart
|
||||
|
||||
**Important Distinction:**
|
||||
|
||||
- **ContentBlock**: Streaming content representation (used in `SessionUpdate`)
|
||||
- Designed for real-time rendering
|
||||
- Granular updates
|
||||
- MCP-compatible structure
|
||||
|
||||
- **MessagePart**: Completed message content (legacy, still supported)
|
||||
- Used in stored/completed messages
|
||||
- May include additional fields for history
|
||||
- Compatibility with existing code
|
||||
|
||||
**Migration Path:** The protocol supports both models. New code should prefer `SessionUpdate` with `ContentBlock` for streaming, while `MessagePart` remains available for compatibility and completed message storage.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Consumers
|
||||
|
||||
1. **Track by message_id**: Group all chunks/updates for the same message
|
||||
2. **Handle tool calls separately**: Maintain a HashMap of tool calls by `tool_call_id`
|
||||
3. **Replace, don't merge**: `ToolCallUpdate` sends complete state, not deltas
|
||||
4. **Use _meta for debugging**: Provider metadata helps with troubleshooting
|
||||
5. **Distinguish thoughts from messages**: Render `AgentThoughtChunk` differently
|
||||
|
||||
### For Adapters
|
||||
|
||||
1. **Always include message_id**: Every update must reference its message
|
||||
2. **Preserve provider info in _meta**: Store original IDs for debugging
|
||||
3. **Send complete tool state**: Include all tool call fields in updates
|
||||
4. **Use appropriate chunk types**: User/Agent/Thought for correct semantics
|
||||
5. **Keep _meta minimal**: Avoid large raw payloads in production
|
||||
|
||||
### For UI Developers
|
||||
|
||||
1. **Stream incrementally**: Append chunks as they arrive
|
||||
2. **Show tool status visually**: Use icons/colors for Pending/Running/Completed/Error
|
||||
3. **Make thoughts collapsible**: Don't clutter the main conversation
|
||||
4. **Handle reconnection**: Be prepared to receive full state snapshots
|
||||
5. **Display errors prominently**: Show tool errors clearly to users
|
||||
|
||||
## Future Extensions
|
||||
|
||||
The following features are planned but not yet implemented:
|
||||
|
||||
- **ResourceBlock**: Embedded resource content (text/blob)
|
||||
- **Image/Audio blocks**: Rich media content
|
||||
- **Plan updates**: Agent planning and mode switching
|
||||
- **Permissions**: Request/reply for user permissions
|
||||
- **Stop reasons**: Detailed completion reasons
|
||||
|
||||
See the protocol roadmap for timeline and details.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Migration from 0.1.x](migration_from_0.1.md) - Upgrading from older versions
|
||||
- [CHANGELOG.md](../CHANGELOG.md) - Version history and breaking changes
|
||||
- [examples/](../examples/) - Code examples demonstrating usage
|
||||
@@ -0,0 +1,100 @@
|
||||
use chrono::Utc;
|
||||
use dirigent_protocol::types::meta::{Meta, ProviderMeta};
|
||||
use dirigent_protocol::{Session, SessionMetadata};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn main() {
|
||||
println!("=== SessionMetadata JSON Examples ===\n");
|
||||
|
||||
// Example 1: Without new fields (backward compatible)
|
||||
println!("1. Basic SessionMetadata (without new fields):");
|
||||
let basic = SessionMetadata {
|
||||
project_path: "/workspace/project".to_string(),
|
||||
model: Some("gpt-4".to_string()),
|
||||
total_messages: 10,
|
||||
system_message: None,
|
||||
current_mode_id: None,
|
||||
_meta: None,
|
||||
project_id: None,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&basic).unwrap();
|
||||
println!("{}\n", json);
|
||||
|
||||
// Example 2: With current_mode_id
|
||||
println!("2. SessionMetadata with current_mode_id:");
|
||||
let with_mode = SessionMetadata {
|
||||
project_path: "/workspace/project".to_string(),
|
||||
model: Some("claude-3-sonnet".to_string()),
|
||||
total_messages: 5,
|
||||
system_message: Some("You are a helpful coding assistant".to_string()),
|
||||
current_mode_id: Some("code_mode".to_string()),
|
||||
_meta: None,
|
||||
project_id: None,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&with_mode).unwrap();
|
||||
println!("{}\n", json);
|
||||
|
||||
// Example 3: With provider metadata
|
||||
println!("3. SessionMetadata with provider metadata:");
|
||||
let meta = Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "opencode".to_string(),
|
||||
original_ids: Some(HashMap::from([
|
||||
("session_id".to_string(), "ses_abc123xyz".to_string()),
|
||||
("project_id".to_string(), "proj_456".to_string()),
|
||||
])),
|
||||
raw_excerpt: Some(serde_json::json!({
|
||||
"version": "0.15.31",
|
||||
"share": null
|
||||
})),
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
|
||||
let with_meta = SessionMetadata {
|
||||
project_path: "/workspace/project".to_string(),
|
||||
model: Some("gpt-4-turbo".to_string()),
|
||||
total_messages: 15,
|
||||
system_message: Some("System prompt here".to_string()),
|
||||
current_mode_id: Some("architect".to_string()),
|
||||
_meta: Some(meta),
|
||||
project_id: None,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&with_meta).unwrap();
|
||||
println!("{}\n", json);
|
||||
|
||||
// Example 4: Full Session object
|
||||
println!("4. Complete Session with all metadata fields:");
|
||||
let now = Utc::now();
|
||||
let session = Session {
|
||||
id: "ses_demo_123".to_string(),
|
||||
title: "My Coding Session".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: with_meta,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
acp_client_id: None,
|
||||
cwd: None,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&session).unwrap();
|
||||
println!("{}\n", json);
|
||||
|
||||
// Example 5: Verify backward compatibility
|
||||
println!("5. Backward compatibility test (deserializing old format):");
|
||||
let old_json = r#"{
|
||||
"project_path": "/old/project",
|
||||
"model": "gpt-3.5",
|
||||
"total_messages": 3
|
||||
}"#;
|
||||
|
||||
let parsed: SessionMetadata = serde_json::from_str(old_json).unwrap();
|
||||
println!("Successfully parsed old JSON format:");
|
||||
println!(" project_path: {}", parsed.project_path);
|
||||
println!(" model: {:?}", parsed.model);
|
||||
println!(" total_messages: {}", parsed.total_messages);
|
||||
println!(" system_message: {:?}", parsed.system_message);
|
||||
println!(" current_mode_id: {:?}", parsed.current_mode_id);
|
||||
println!(" _meta: {:?}", parsed._meta);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
//! Protocol-level message accumulator for incremental message assembly.
|
||||
//!
|
||||
//! Handles streaming message deltas and assembles them into complete
|
||||
//! [`AccumulatedMessage`] values using protocol types. Unlike the archivist's
|
||||
//! accumulator, this module produces protocol-native output without UUID
|
||||
//! parsing, markdown generation, or storage metadata.
|
||||
//!
|
||||
//! The accumulator preserves the order of content parts (text, thinking, tool
|
||||
//! calls) as they arrive in the event stream, enabling inline tool rendering
|
||||
//! and faithful forwarding to downstream consumers.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::conversation::MessagePart;
|
||||
use crate::types::ContentBlock;
|
||||
|
||||
/// Tool call data accumulated during streaming.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCallData {
|
||||
pub id: String,
|
||||
pub tool_name: String,
|
||||
pub input: Value,
|
||||
pub output: Option<Value>,
|
||||
}
|
||||
|
||||
/// A single accumulated content part, preserving event-stream order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AccumulatedPart {
|
||||
Text { text: String },
|
||||
Thinking { text: String },
|
||||
Tool { data: ToolCallData },
|
||||
}
|
||||
|
||||
/// A fully accumulated message assembled from streaming chunks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccumulatedMessage {
|
||||
pub message_id: String,
|
||||
pub session_id: String,
|
||||
pub connector_id: String,
|
||||
pub role: String,
|
||||
pub parts: Vec<AccumulatedPart>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub last_activity: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AccumulatedMessage {
|
||||
/// Build an `AccumulatedMessage` directly from a slice of [`MessagePart`]s.
|
||||
///
|
||||
/// This is the path for non-streaming clients that deliver content in a
|
||||
/// single `MessageCompleted` event rather than via incremental chunks.
|
||||
pub fn from_message_parts(
|
||||
message_id: String,
|
||||
session_id: String,
|
||||
connector_id: String,
|
||||
role: String,
|
||||
parts: &[MessagePart],
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
let mut accumulated_parts = Vec::new();
|
||||
|
||||
for part in parts {
|
||||
match part {
|
||||
MessagePart::Text { text } => {
|
||||
if !text.is_empty() {
|
||||
accumulated_parts.push(AccumulatedPart::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
MessagePart::Thinking { text } => {
|
||||
if !text.is_empty() {
|
||||
accumulated_parts.push(AccumulatedPart::Thinking { text: text.clone() });
|
||||
}
|
||||
}
|
||||
MessagePart::Code { language, code } => {
|
||||
if !code.is_empty() {
|
||||
let fenced = format!("```{}\n{}\n```", language, code);
|
||||
accumulated_parts.push(AccumulatedPart::Text { text: fenced });
|
||||
}
|
||||
}
|
||||
MessagePart::Tool {
|
||||
tool,
|
||||
tool_call_id,
|
||||
input,
|
||||
output,
|
||||
} => {
|
||||
accumulated_parts.push(AccumulatedPart::Tool {
|
||||
data: ToolCallData {
|
||||
id: tool_call_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::new()),
|
||||
tool_name: tool.clone(),
|
||||
input: input.clone(),
|
||||
output: output.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
MessagePart::File { path, content: _ } => {
|
||||
let label = format!("\u{1f4c4} File: {}", path);
|
||||
accumulated_parts.push(AccumulatedPart::Text { text: label });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
message_id,
|
||||
session_id,
|
||||
connector_id,
|
||||
role,
|
||||
parts: accumulated_parts,
|
||||
created_at: Some(now),
|
||||
last_activity: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert accumulated parts back to protocol [`MessagePart`]s.
|
||||
pub fn to_message_parts(&self) -> Vec<MessagePart> {
|
||||
self.parts
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
AccumulatedPart::Text { text } => MessagePart::Text { text: text.clone() },
|
||||
AccumulatedPart::Thinking { text } => {
|
||||
MessagePart::Thinking { text: text.clone() }
|
||||
}
|
||||
AccumulatedPart::Tool { data } => MessagePart::Tool {
|
||||
tool: data.tool_name.clone(),
|
||||
tool_call_id: if data.id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data.id.clone())
|
||||
},
|
||||
input: data.input.clone(),
|
||||
output: data.output.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns `true` when the message has no accumulated content.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.parts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Buffer for accumulating streaming chunks into a complete message.
|
||||
#[derive(Debug)]
|
||||
struct MessageBuffer {
|
||||
message_id: String,
|
||||
session_id: String,
|
||||
connector_id: String,
|
||||
role: String,
|
||||
parts: Vec<AccumulatedPart>,
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
last_activity: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl MessageBuffer {
|
||||
fn new(message_id: String, session_id: String, connector_id: String, role: String) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
message_id,
|
||||
session_id,
|
||||
connector_id,
|
||||
role,
|
||||
parts: Vec::new(),
|
||||
created_at: None,
|
||||
last_activity: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
let now = Utc::now();
|
||||
if self.created_at.is_none() {
|
||||
self.created_at = Some(now);
|
||||
}
|
||||
self.last_activity = now;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageAccumulator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Accumulator for assembling streaming message deltas into complete messages.
|
||||
///
|
||||
/// Each in-flight message is identified by its `message_id` and tracked in an
|
||||
/// internal buffer. Text and thinking chunks are coalesced when consecutive;
|
||||
/// tool calls are deduplicated by `tool_call_id`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MessageAccumulator {
|
||||
buffers: HashMap<String, MessageBuffer>,
|
||||
}
|
||||
|
||||
impl MessageAccumulator {
|
||||
/// Create a new, empty accumulator.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buffers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a content chunk to the message buffer.
|
||||
///
|
||||
/// Consecutive text chunks are coalesced into a single `AccumulatedPart::Text`.
|
||||
pub fn add_chunk(
|
||||
&mut self,
|
||||
message_id: &str,
|
||||
session_id: &str,
|
||||
connector_id: &str,
|
||||
role: &str,
|
||||
content: ContentBlock,
|
||||
) {
|
||||
let buffer = self
|
||||
.buffers
|
||||
.entry(message_id.to_string())
|
||||
.or_insert_with(|| {
|
||||
MessageBuffer::new(
|
||||
message_id.to_string(),
|
||||
session_id.to_string(),
|
||||
connector_id.to_string(),
|
||||
role.to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
buffer.touch();
|
||||
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
if let Some(AccumulatedPart::Text { text: existing }) = buffer.parts.last_mut() {
|
||||
existing.push_str(&text);
|
||||
} else {
|
||||
buffer.parts.push(AccumulatedPart::Text { text });
|
||||
}
|
||||
}
|
||||
ContentBlock::ResourceLink { .. } => {
|
||||
// ResourceLink is not accumulated as text content for now.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add thinking content to the message buffer.
|
||||
///
|
||||
/// Consecutive thinking chunks are coalesced into a single
|
||||
/// `AccumulatedPart::Thinking`.
|
||||
pub fn add_thinking(
|
||||
&mut self,
|
||||
message_id: &str,
|
||||
session_id: &str,
|
||||
connector_id: &str,
|
||||
content: &str,
|
||||
) {
|
||||
let buffer = self
|
||||
.buffers
|
||||
.entry(message_id.to_string())
|
||||
.or_insert_with(|| {
|
||||
MessageBuffer::new(
|
||||
message_id.to_string(),
|
||||
session_id.to_string(),
|
||||
connector_id.to_string(),
|
||||
"assistant".to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
buffer.touch();
|
||||
|
||||
if let Some(AccumulatedPart::Thinking { text: existing }) = buffer.parts.last_mut() {
|
||||
existing.push_str(content);
|
||||
} else {
|
||||
buffer
|
||||
.parts
|
||||
.push(AccumulatedPart::Thinking { text: content.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or update a tool call in the message buffer.
|
||||
///
|
||||
/// If a tool call with the same `id` already exists in the buffer, the
|
||||
/// existing entry is updated (input is overwritten only when non-empty;
|
||||
/// output is overwritten when `Some`). Otherwise a new entry is appended,
|
||||
/// preserving event-stream ordering.
|
||||
pub fn add_or_update_tool_call(&mut self, message_id: &str, tool_call: ToolCallData) {
|
||||
if let Some(buffer) = self.buffers.get_mut(message_id) {
|
||||
buffer.last_activity = Utc::now();
|
||||
|
||||
// Try to find and update an existing tool call with the same id.
|
||||
for part in buffer.parts.iter_mut() {
|
||||
if let AccumulatedPart::Tool { data } = part {
|
||||
if data.id == tool_call.id {
|
||||
data.tool_name = tool_call.tool_name;
|
||||
|
||||
if tool_call.input != Value::Null
|
||||
&& tool_call.input != serde_json::json!({})
|
||||
{
|
||||
data.input = tool_call.input;
|
||||
}
|
||||
|
||||
if tool_call.output.is_some() {
|
||||
data.output = tool_call.output;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First time seeing this tool_call_id -- append.
|
||||
buffer
|
||||
.parts
|
||||
.push(AccumulatedPart::Tool { data: tool_call });
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize a message and return its accumulated content.
|
||||
///
|
||||
/// The internal buffer for `message_id` is removed. Returns `None` if no
|
||||
/// buffer exists for the given id.
|
||||
pub fn finalize(&mut self, message_id: &str) -> Option<AccumulatedMessage> {
|
||||
let buffer = self.buffers.remove(message_id)?;
|
||||
|
||||
Some(AccumulatedMessage {
|
||||
message_id: buffer.message_id,
|
||||
session_id: buffer.session_id,
|
||||
connector_id: buffer.connector_id,
|
||||
role: buffer.role,
|
||||
parts: buffer.parts,
|
||||
created_at: buffer.created_at,
|
||||
last_activity: buffer.last_activity,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` if a buffer exists for the given message id.
|
||||
pub fn has_buffer(&self, message_id: &str) -> bool {
|
||||
self.buffers.contains_key(message_id)
|
||||
}
|
||||
|
||||
/// Return all buffered message ids that belong to `session_id`.
|
||||
pub fn message_ids_for_session(&self, session_id: &str) -> Vec<String> {
|
||||
self.buffers
|
||||
.iter()
|
||||
.filter(|(_, buf)| buf.session_id == session_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return all currently buffered message ids.
|
||||
pub fn active_message_ids(&self) -> Vec<String> {
|
||||
self.buffers.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Return message ids whose buffers have not been touched for longer than
|
||||
/// `threshold`.
|
||||
pub fn stale_message_ids(&self, threshold: std::time::Duration) -> Vec<String> {
|
||||
let now = Utc::now();
|
||||
self.buffers
|
||||
.iter()
|
||||
.filter(|(_, buf)| {
|
||||
let inactive = now.signed_duration_since(buf.last_activity);
|
||||
inactive
|
||||
.to_std()
|
||||
.unwrap_or(std::time::Duration::ZERO)
|
||||
> threshold
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_accumulator() {
|
||||
let mut acc = MessageAccumulator::new();
|
||||
assert!(acc.finalize("nonexistent").is_none());
|
||||
assert!(acc.active_message_ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_chunk_coalescing() {
|
||||
let mut acc = MessageAccumulator::new();
|
||||
|
||||
acc.add_chunk("msg1", "s1", "c1", "user", ContentBlock::Text {
|
||||
text: "Hello, ".to_string(),
|
||||
});
|
||||
acc.add_chunk("msg1", "s1", "c1", "user", ContentBlock::Text {
|
||||
text: "world!".to_string(),
|
||||
});
|
||||
|
||||
let msg = acc.finalize("msg1").unwrap();
|
||||
assert_eq!(msg.parts.len(), 1);
|
||||
match &msg.parts[0] {
|
||||
AccumulatedPart::Text { text } => assert_eq!(text, "Hello, world!"),
|
||||
other => panic!("Expected Text, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_coalescing() {
|
||||
let mut acc = MessageAccumulator::new();
|
||||
|
||||
acc.add_thinking("msg1", "s1", "c1", "First. ");
|
||||
acc.add_thinking("msg1", "s1", "c1", "Second.");
|
||||
|
||||
let msg = acc.finalize("msg1").unwrap();
|
||||
assert_eq!(msg.parts.len(), 1);
|
||||
match &msg.parts[0] {
|
||||
AccumulatedPart::Thinking { text } => assert_eq!(text, "First. Second."),
|
||||
other => panic!("Expected Thinking, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interleaved_parts_preserve_order() {
|
||||
let mut acc = MessageAccumulator::new();
|
||||
|
||||
// text, tool, text -- should produce 3 distinct parts
|
||||
acc.add_chunk("msg1", "s1", "c1", "assistant", ContentBlock::Text {
|
||||
text: "Before tool.".to_string(),
|
||||
});
|
||||
|
||||
acc.add_or_update_tool_call("msg1", ToolCallData {
|
||||
id: "tc1".to_string(),
|
||||
tool_name: "grep".to_string(),
|
||||
input: serde_json::json!({"q": "x"}),
|
||||
output: None,
|
||||
});
|
||||
|
||||
acc.add_chunk("msg1", "s1", "c1", "assistant", ContentBlock::Text {
|
||||
text: "After tool.".to_string(),
|
||||
});
|
||||
|
||||
let msg = acc.finalize("msg1").unwrap();
|
||||
assert_eq!(msg.parts.len(), 3);
|
||||
assert!(matches!(&msg.parts[0], AccumulatedPart::Text { .. }));
|
||||
assert!(matches!(&msg.parts[1], AccumulatedPart::Tool { .. }));
|
||||
assert!(matches!(&msg.parts[2], AccumulatedPart::Text { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_deduplication() {
|
||||
let mut acc = MessageAccumulator::new();
|
||||
|
||||
// Create buffer first
|
||||
acc.add_chunk("msg1", "s1", "c1", "assistant", ContentBlock::Text {
|
||||
text: "hi".to_string(),
|
||||
});
|
||||
|
||||
// Initial tool call
|
||||
acc.add_or_update_tool_call("msg1", ToolCallData {
|
||||
id: "tc1".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
input: serde_json::json!({"path": "foo.rs"}),
|
||||
output: None,
|
||||
});
|
||||
|
||||
// Update same tool call with output (empty input should NOT overwrite)
|
||||
acc.add_or_update_tool_call("msg1", ToolCallData {
|
||||
id: "tc1".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
input: serde_json::json!({}),
|
||||
output: Some(serde_json::json!({"content": "fn main() {}"})),
|
||||
});
|
||||
|
||||
let msg = acc.finalize("msg1").unwrap();
|
||||
// text + 1 tool (not 2)
|
||||
assert_eq!(msg.parts.len(), 2);
|
||||
|
||||
match &msg.parts[1] {
|
||||
AccumulatedPart::Tool { data } => {
|
||||
assert_eq!(data.id, "tc1");
|
||||
// Input preserved from first call (non-empty), not overwritten by empty update
|
||||
assert_eq!(data.input, serde_json::json!({"path": "foo.rs"}));
|
||||
// Output set from update
|
||||
assert_eq!(
|
||||
data.output,
|
||||
Some(serde_json::json!({"content": "fn main() {}"}))
|
||||
);
|
||||
}
|
||||
other => panic!("Expected Tool, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_message_parts_non_streaming() {
|
||||
let parts = vec![
|
||||
MessagePart::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
MessagePart::Thinking {
|
||||
text: "hmm".to_string(),
|
||||
},
|
||||
MessagePart::Code {
|
||||
language: "rs".to_string(),
|
||||
code: "fn main() {}".to_string(),
|
||||
},
|
||||
MessagePart::Tool {
|
||||
tool: "grep".to_string(),
|
||||
tool_call_id: Some("tc1".to_string()),
|
||||
input: serde_json::json!({"q": "x"}),
|
||||
output: Some(serde_json::json!("found")),
|
||||
},
|
||||
MessagePart::File {
|
||||
path: "README.md".to_string(),
|
||||
content: "# Title".to_string(),
|
||||
},
|
||||
// Empty text and code should be skipped
|
||||
MessagePart::Text {
|
||||
text: String::new(),
|
||||
},
|
||||
MessagePart::Code {
|
||||
language: "py".to_string(),
|
||||
code: String::new(),
|
||||
},
|
||||
];
|
||||
|
||||
let msg = AccumulatedMessage::from_message_parts(
|
||||
"msg1".into(),
|
||||
"s1".into(),
|
||||
"c1".into(),
|
||||
"assistant".into(),
|
||||
&parts,
|
||||
);
|
||||
|
||||
// 5 non-empty parts: text, thinking, code-as-text, tool, file-as-text
|
||||
assert_eq!(msg.parts.len(), 5);
|
||||
|
||||
match &msg.parts[0] {
|
||||
AccumulatedPart::Text { text } => assert_eq!(text, "Hello"),
|
||||
other => panic!("Expected Text, got {:?}", other),
|
||||
}
|
||||
match &msg.parts[1] {
|
||||
AccumulatedPart::Thinking { text } => assert_eq!(text, "hmm"),
|
||||
other => panic!("Expected Thinking, got {:?}", other),
|
||||
}
|
||||
match &msg.parts[2] {
|
||||
AccumulatedPart::Text { text } => {
|
||||
assert!(text.contains("```rs"));
|
||||
assert!(text.contains("fn main() {}"));
|
||||
}
|
||||
other => panic!("Expected Text (code), got {:?}", other),
|
||||
}
|
||||
match &msg.parts[3] {
|
||||
AccumulatedPart::Tool { data } => {
|
||||
assert_eq!(data.tool_name, "grep");
|
||||
assert_eq!(data.id, "tc1");
|
||||
assert_eq!(data.output, Some(serde_json::json!("found")));
|
||||
}
|
||||
other => panic!("Expected Tool, got {:?}", other),
|
||||
}
|
||||
match &msg.parts[4] {
|
||||
AccumulatedPart::Text { text } => {
|
||||
assert!(text.contains("File: README.md"));
|
||||
}
|
||||
other => panic!("Expected Text (file), got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_and_stale_queries() {
|
||||
let mut acc = MessageAccumulator::new();
|
||||
|
||||
acc.add_chunk("msg1", "s1", "c1", "user", ContentBlock::Text {
|
||||
text: "a".to_string(),
|
||||
});
|
||||
acc.add_chunk("msg2", "s1", "c1", "assistant", ContentBlock::Text {
|
||||
text: "b".to_string(),
|
||||
});
|
||||
acc.add_chunk("msg3", "s2", "c1", "user", ContentBlock::Text {
|
||||
text: "c".to_string(),
|
||||
});
|
||||
|
||||
// message_ids_for_session
|
||||
let mut s1_ids = acc.message_ids_for_session("s1");
|
||||
s1_ids.sort();
|
||||
assert_eq!(s1_ids, vec!["msg1", "msg2"]);
|
||||
|
||||
let s2_ids = acc.message_ids_for_session("s2");
|
||||
assert_eq!(s2_ids, vec!["msg3"]);
|
||||
|
||||
assert!(acc.message_ids_for_session("s3").is_empty());
|
||||
|
||||
// active_message_ids
|
||||
let mut all = acc.active_message_ids();
|
||||
all.sort();
|
||||
assert_eq!(all, vec!["msg1", "msg2", "msg3"]);
|
||||
|
||||
// has_buffer
|
||||
assert!(acc.has_buffer("msg1"));
|
||||
assert!(!acc.has_buffer("msg99"));
|
||||
|
||||
// stale_message_ids with zero threshold -- everything is stale
|
||||
// (last_activity <= now)
|
||||
let _stale_zero = acc.stale_message_ids(std::time::Duration::ZERO);
|
||||
// All three should be considered stale since last_activity <= now
|
||||
// (Due to timing, they might not all be strictly < now, so we check
|
||||
// with a small threshold instead.)
|
||||
let stale_lenient = acc.stale_message_ids(std::time::Duration::from_secs(0));
|
||||
// At minimum, none should be stale with a huge threshold
|
||||
let not_stale = acc.stale_message_ids(std::time::Duration::from_secs(3600));
|
||||
assert!(not_stale.is_empty());
|
||||
|
||||
// Verify stale detection works by checking the lenient case doesn't
|
||||
// return more ids than we have buffers
|
||||
assert!(stale_lenient.len() <= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_message_parts() {
|
||||
let mut acc = MessageAccumulator::new();
|
||||
|
||||
acc.add_chunk("msg1", "s1", "c1", "assistant", ContentBlock::Text {
|
||||
text: "Hello ".to_string(),
|
||||
});
|
||||
acc.add_chunk("msg1", "s1", "c1", "assistant", ContentBlock::Text {
|
||||
text: "world.".to_string(),
|
||||
});
|
||||
acc.add_thinking("msg1", "s1", "c1", "thinking...");
|
||||
acc.add_or_update_tool_call("msg1", ToolCallData {
|
||||
id: "tc1".to_string(),
|
||||
tool_name: "search".to_string(),
|
||||
input: serde_json::json!({"q": "test"}),
|
||||
output: Some(serde_json::json!("result")),
|
||||
});
|
||||
|
||||
let msg = acc.finalize("msg1").unwrap();
|
||||
let parts = msg.to_message_parts();
|
||||
|
||||
assert_eq!(parts.len(), 3);
|
||||
|
||||
// Coalesced text
|
||||
match &parts[0] {
|
||||
MessagePart::Text { text } => assert_eq!(text, "Hello world."),
|
||||
other => panic!("Expected Text, got {:?}", other),
|
||||
}
|
||||
|
||||
// Thinking
|
||||
match &parts[1] {
|
||||
MessagePart::Thinking { text } => assert_eq!(text, "thinking..."),
|
||||
other => panic!("Expected Thinking, got {:?}", other),
|
||||
}
|
||||
|
||||
// Tool roundtrip
|
||||
match &parts[2] {
|
||||
MessagePart::Tool {
|
||||
tool,
|
||||
tool_call_id,
|
||||
input,
|
||||
output,
|
||||
} => {
|
||||
assert_eq!(tool, "search");
|
||||
assert_eq!(tool_call_id, &Some("tc1".to_string()));
|
||||
assert_eq!(input, &serde_json::json!({"q": "test"}));
|
||||
assert_eq!(output, &Some(serde_json::json!("result")));
|
||||
}
|
||||
other => panic!("Expected Tool, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_empty() {
|
||||
let msg = AccumulatedMessage::from_message_parts(
|
||||
"msg1".into(),
|
||||
"s1".into(),
|
||||
"c1".into(),
|
||||
"user".into(),
|
||||
&[],
|
||||
);
|
||||
assert!(msg.is_empty());
|
||||
|
||||
let msg2 = AccumulatedMessage::from_message_parts(
|
||||
"msg2".into(),
|
||||
"s1".into(),
|
||||
"c1".into(),
|
||||
"user".into(),
|
||||
&[MessagePart::Text {
|
||||
text: "hi".to_string(),
|
||||
}],
|
||||
);
|
||||
assert!(!msg2.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_message_parts_empty_tool_id() {
|
||||
// Tool with no tool_call_id should roundtrip as None
|
||||
let parts = vec![MessagePart::Tool {
|
||||
tool: "bash".to_string(),
|
||||
tool_call_id: None,
|
||||
input: serde_json::json!({"cmd": "ls"}),
|
||||
output: None,
|
||||
}];
|
||||
|
||||
let msg = AccumulatedMessage::from_message_parts(
|
||||
"msg1".into(),
|
||||
"s1".into(),
|
||||
"c1".into(),
|
||||
"assistant".into(),
|
||||
&parts,
|
||||
);
|
||||
|
||||
let roundtripped = msg.to_message_parts();
|
||||
match &roundtripped[0] {
|
||||
MessagePart::Tool { tool_call_id, .. } => {
|
||||
assert_eq!(tool_call_id, &None);
|
||||
}
|
||||
other => panic!("Expected Tool, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
pub mod acp;
|
||||
|
||||
#[cfg(feature = "adapters")]
|
||||
pub mod opencode;
|
||||
#[cfg(feature = "adapters")]
|
||||
pub mod rest;
|
||||
|
||||
pub use acp::{AcpAdapter, AcpTranslationError};
|
||||
|
||||
#[cfg(feature = "adapters")]
|
||||
pub use opencode::{OpenCodeAdapter, TranslationError};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
/// REST API conversion helpers
|
||||
///
|
||||
/// Converts OpenCode REST API responses to Dirigent protocol types
|
||||
use crate::{
|
||||
Message, MessageMetadata, MessagePart, MessageRole, MessageStatus, Session, SessionMetadata,
|
||||
};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use opencode_client::types as oc;
|
||||
|
||||
/// Convert OpenCode Session to Dirigent Session
|
||||
pub fn convert_session(oc_session: oc::Session) -> Session {
|
||||
Session {
|
||||
id: oc_session.id,
|
||||
title: oc_session.title,
|
||||
created_at: timestamp_to_datetime(oc_session.time.created),
|
||||
updated_at: timestamp_to_datetime(oc_session.time.updated),
|
||||
metadata: SessionMetadata {
|
||||
project_path: oc_session.directory,
|
||||
model: None, // Not available in session info
|
||||
total_messages: 0, // Would need to be calculated separately
|
||||
system_message: None, // Will be set from first assistant message
|
||||
current_mode_id: None,
|
||||
_meta: None,
|
||||
project_id: None,
|
||||
},
|
||||
cwd: None, // OpenCode REST doesn't expose cwd separately from project_path
|
||||
models: None, // OpenCode doesn't provide ACP model state
|
||||
modes: None, // OpenCode doesn't provide ACP mode state
|
||||
config_options: None,
|
||||
acp_client_id: None, // OpenCode doesn't have ACP client ID
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert OpenCode Message to Dirigent Message
|
||||
pub fn convert_message(oc_msg: oc::Message) -> Message {
|
||||
let (id, session_id, role, created_at, status, metadata) = match oc_msg {
|
||||
oc::Message::User(u) => (
|
||||
u.id,
|
||||
u.session_id,
|
||||
MessageRole::User,
|
||||
timestamp_to_datetime(u.time.created),
|
||||
MessageStatus::Completed,
|
||||
None, // User messages don't have metadata
|
||||
),
|
||||
oc::Message::Assistant(a) => {
|
||||
let status = if let Some(err) = a.error {
|
||||
MessageStatus::Failed {
|
||||
error: format_message_error(&err),
|
||||
}
|
||||
} else if a.time.completed.is_some() {
|
||||
MessageStatus::Completed
|
||||
} else {
|
||||
MessageStatus::Streaming
|
||||
};
|
||||
|
||||
// Extract metadata from assistant message
|
||||
let metadata = Some(MessageMetadata {
|
||||
cost: Some(a.cost),
|
||||
tokens_input: Some(a.tokens.input),
|
||||
tokens_output: Some(a.tokens.output),
|
||||
response_time_ms: None,
|
||||
latency_ms: None,
|
||||
model: a.model_id.clone(),
|
||||
other: None,
|
||||
});
|
||||
|
||||
(
|
||||
a.id,
|
||||
a.session_id,
|
||||
MessageRole::Assistant,
|
||||
timestamp_to_datetime(a.time.created),
|
||||
status,
|
||||
metadata,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Message {
|
||||
id,
|
||||
session_id,
|
||||
role,
|
||||
created_at,
|
||||
content: vec![], // Parts are separate
|
||||
status,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert OpenCode MessageWithParts to Dirigent Message with parts
|
||||
pub fn convert_message_with_parts(oc_msg: oc::MessageWithParts) -> Message {
|
||||
let mut message = convert_message(oc_msg.info);
|
||||
|
||||
// Convert parts
|
||||
message.content = oc_msg
|
||||
.parts
|
||||
.into_iter()
|
||||
.filter_map(|part| convert_part(part))
|
||||
.collect();
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
/// Convert OpenCode Part to Dirigent MessagePart
|
||||
fn convert_part(oc_part: oc::Part) -> Option<MessagePart> {
|
||||
match oc_part {
|
||||
oc::Part::Text(t) => Some(MessagePart::Text { text: t.text }),
|
||||
oc::Part::Reasoning(r) => Some(MessagePart::Thinking { text: r.text }),
|
||||
oc::Part::Tool(t) => {
|
||||
let (input, output) = match t.state {
|
||||
oc::ToolState::Pending => (serde_json::Value::Null, None),
|
||||
oc::ToolState::Running { input, .. } => (input, None),
|
||||
oc::ToolState::Completed { input, output, .. } => {
|
||||
(input, Some(serde_json::Value::String(output)))
|
||||
}
|
||||
oc::ToolState::Error { input, error, .. } => {
|
||||
(input, Some(serde_json::json!({ "error": error })))
|
||||
}
|
||||
};
|
||||
|
||||
Some(MessagePart::Tool {
|
||||
tool: t.tool,
|
||||
tool_call_id: None,
|
||||
input,
|
||||
output,
|
||||
})
|
||||
}
|
||||
oc::Part::File(f) => Some(MessagePart::File {
|
||||
path: f.filename.unwrap_or_else(|| f.url.clone()),
|
||||
content: f.url,
|
||||
}),
|
||||
// Skip unsupported part types
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Unix timestamp (milliseconds) to DateTime<Utc>
|
||||
fn timestamp_to_datetime(timestamp: u64) -> DateTime<Utc> {
|
||||
Utc.timestamp_millis_opt(timestamp as i64)
|
||||
.single()
|
||||
.unwrap_or_else(|| Utc::now())
|
||||
}
|
||||
|
||||
/// Format a message error into a user-friendly string
|
||||
fn format_message_error(error: &oc::MessageError) -> String {
|
||||
match error {
|
||||
oc::MessageError::ProviderAuthError { data } => {
|
||||
format!(
|
||||
"Authentication error for {}: {}",
|
||||
data.provider_id, data.message
|
||||
)
|
||||
}
|
||||
oc::MessageError::UnknownError { data } => {
|
||||
format!("Unknown error: {}", data.message)
|
||||
}
|
||||
oc::MessageError::MessageOutputLengthError => "Message output length exceeded".to_string(),
|
||||
oc::MessageError::MessageAbortedError { data } => {
|
||||
format!("Message aborted: {}", data.message)
|
||||
}
|
||||
oc::MessageError::ApiError { data } => {
|
||||
if let Some(status) = data.status_code {
|
||||
format!("API error ({}): {}", status, data.message)
|
||||
} else {
|
||||
format!("API error: {}", data.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
pub session_id: String,
|
||||
pub role: MessageRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub content: Vec<MessagePart>,
|
||||
pub status: MessageStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<MessageMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MessageMetadata {
|
||||
// Cost information
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tokens_input: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tokens_output: Option<u64>,
|
||||
|
||||
// Performance metrics
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_time_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latency_ms: Option<u64>,
|
||||
|
||||
// Model information
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
// Arbitrary metadata from connector clients
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub other: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum MessageRole {
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum MessageStatus {
|
||||
Pending,
|
||||
Streaming,
|
||||
Completed,
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum MessagePart {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Thinking {
|
||||
text: String,
|
||||
},
|
||||
Code {
|
||||
language: String,
|
||||
code: String,
|
||||
},
|
||||
Tool {
|
||||
tool: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
input: Value,
|
||||
output: Option<Value>,
|
||||
},
|
||||
File {
|
||||
path: String,
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
use crate::session::{ConfigOption, SessionModeState, SessionModelState};
|
||||
use crate::{Message, Session, SessionUpdate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Reason why the turn was marked complete (for debugging/observability)
|
||||
///
|
||||
/// This enum indicates **how** the system determined that a turn has completed.
|
||||
/// Different connector types use different strategies:
|
||||
///
|
||||
/// - **OpenCode Connector**: Uses `ExplicitSignal` (upstream session.idle event)
|
||||
/// - **ACP Connector (stdio)**: Uses `ResponseReceived` (JSON-RPC response is final)
|
||||
/// - **Gateway Connector**: Uses `OperationsComplete` (tracks pending tool calls)
|
||||
/// - **Fallback**: Uses `IdleTimeout` when no other signal available
|
||||
///
|
||||
/// # Consumer Usage
|
||||
///
|
||||
/// Most consumers should treat all triggers the same (turn is complete).
|
||||
/// The trigger type is primarily for debugging and observability.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum TurnCompleteTrigger {
|
||||
/// Explicit signal from upstream provider (e.g., OpenCode session.idle event)
|
||||
///
|
||||
/// This is the most reliable trigger as it comes directly from the agent system.
|
||||
ExplicitSignal,
|
||||
|
||||
/// JSON-RPC response received (ACP stdio transport)
|
||||
///
|
||||
/// In ACP stdio mode, the response message is the last message in the turn.
|
||||
ResponseReceived,
|
||||
|
||||
/// All tracked operations completed (e.g., pending tool calls resolved)
|
||||
///
|
||||
/// Used when the connector tracks operation state and can determine
|
||||
/// completion by monitoring tool call statuses.
|
||||
OperationsComplete,
|
||||
|
||||
/// Timeout-based idle detection (fallback mechanism)
|
||||
///
|
||||
/// Used when no other completion signal is available.
|
||||
/// The duration indicates how long the system waited before declaring completion.
|
||||
IdleTimeout { duration_ms: u64 },
|
||||
}
|
||||
|
||||
/// A single node in an inspector snapshot (protocol-level DTO).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InspectorSnapshotNode {
|
||||
pub id: String,
|
||||
pub parent: Option<String>,
|
||||
pub children: Vec<String>,
|
||||
pub label: String,
|
||||
pub kind: String,
|
||||
pub state: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state_detail: Option<String>,
|
||||
pub properties: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
pub created_at: String,
|
||||
pub last_updated: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum Event {
|
||||
// Session events
|
||||
SessionsListed {
|
||||
connector_id: String,
|
||||
sessions: Vec<Session>,
|
||||
},
|
||||
SessionCreated {
|
||||
connector_id: String,
|
||||
session: Session,
|
||||
},
|
||||
SessionUpdated {
|
||||
connector_id: String,
|
||||
session: Session,
|
||||
},
|
||||
SessionMetadataUpdated {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
title: Option<String>,
|
||||
total_messages: Option<u32>,
|
||||
model: Option<String>,
|
||||
},
|
||||
SessionDeleted {
|
||||
session_id: String,
|
||||
},
|
||||
/// Session was closed (agent released resources, session remains in list).
|
||||
/// The session can be loaded again later via session/load.
|
||||
SessionClosed {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
},
|
||||
SessionSystemMessageSet {
|
||||
session_id: String,
|
||||
system_message: String,
|
||||
},
|
||||
SessionIdle {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
},
|
||||
/// Session mode/model metadata received from an ACP connector
|
||||
///
|
||||
/// Emitted when metadata is received from a connector (e.g., after session/new or session/load).
|
||||
/// This event is separate from SessionCreated to support:
|
||||
/// - Session takeover scenarios (session already exists, but metadata is new)
|
||||
/// - Specific subscriptions to metadata changes
|
||||
/// - Connectors that provide metadata asynchronously
|
||||
///
|
||||
/// # Fields
|
||||
/// - `models`: UNSTABLE in ACP spec but used by Claude-ACP
|
||||
/// - `modes`: Stable in ACP spec
|
||||
///
|
||||
/// Both fields are optional since not all connectors provide this data.
|
||||
SessionMetadataReceived {
|
||||
/// Connector that provided the metadata
|
||||
connector_id: String,
|
||||
/// Session the metadata belongs to
|
||||
session_id: String,
|
||||
/// Available models and current model (UNSTABLE in ACP spec)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
models: Option<SessionModelState>,
|
||||
/// Available modes and current mode
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
modes: Option<SessionModeState>,
|
||||
/// ACP config options (replaces modes/models in future ACP versions)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
config_options: Option<Vec<ConfigOption>>,
|
||||
},
|
||||
|
||||
/// **All content for this turn/message has been received.**
|
||||
///
|
||||
/// This is the **primary signal** for finalization actions (archiving, UI state lock).
|
||||
/// Emitted BEFORE `SessionIdle` to ensure proper event ordering.
|
||||
///
|
||||
/// # Event Semantics
|
||||
///
|
||||
/// - **`MessageCompleted`**: Message metadata is ready (informational)
|
||||
/// - Purpose: UI status updates ("Assistant is typing" → "Complete")
|
||||
/// - Timing: Sent when message record exists, content may still be streaming
|
||||
/// - Consumer action: Update UI state, show completion status
|
||||
///
|
||||
/// - **`TurnComplete`**: All content received (actionable)
|
||||
/// - Purpose: Signal that the entire turn is finalized
|
||||
/// - Timing: Sent AFTER all content chunks, tool calls, and metadata
|
||||
/// - Consumer action: Finalize storage, lock state, trigger post-processing
|
||||
///
|
||||
/// - **`SessionIdle`**: No recent activity (informational)
|
||||
/// - Purpose: UI spinner control, activity indication
|
||||
/// - Timing: Sent AFTER `TurnComplete` as final safety signal
|
||||
/// - Consumer action: Hide spinners, update activity indicators
|
||||
///
|
||||
/// # Event Ordering
|
||||
///
|
||||
/// ```text
|
||||
/// 1. MessageStarted (message created)
|
||||
/// 2. SessionUpdate::*Chunk (content streaming)
|
||||
/// 3. SessionUpdate::ToolCall* (tool execution)
|
||||
/// 4. MessageCompleted (metadata ready)
|
||||
/// 5. TurnComplete ← YOU ARE HERE (finalize!)
|
||||
/// 6. SessionIdle (activity stopped)
|
||||
/// ```
|
||||
///
|
||||
/// # Consumer Behavior
|
||||
///
|
||||
/// | Consumer | MessageCompleted | TurnComplete | SessionIdle |
|
||||
/// |----------|------------------|--------------|-------------|
|
||||
/// | **Archivist** | Ignore | **Finalize and write** | Safety net |
|
||||
/// | **UI Cache** | Update status | **Lock state** | Hide spinner |
|
||||
/// | **Conductor Bridge** | - | **Flush response** | Fallback flush |
|
||||
///
|
||||
/// # Example Usage
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_protocol::{Event, TurnCompleteTrigger};
|
||||
///
|
||||
/// match event {
|
||||
/// Event::TurnComplete { session_id, message_id, trigger, .. } => {
|
||||
/// // Finalize the message in your storage
|
||||
/// archivist.finalize_message(&session_id, &message_id).await?;
|
||||
///
|
||||
/// // Lock UI state
|
||||
/// ui_cache.lock_message(&message_id);
|
||||
///
|
||||
/// // Log trigger for debugging
|
||||
/// println!("Turn complete via {:?}", trigger);
|
||||
/// }
|
||||
/// _ => {}
|
||||
/// }
|
||||
/// ```
|
||||
TurnComplete {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
message_id: String,
|
||||
trigger: TurnCompleteTrigger,
|
||||
},
|
||||
/// Session-level error that can be displayed in the chat UI.
|
||||
/// Used when a connector encounters an error during session operations.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// - `error_message`: Human-readable error summary
|
||||
/// - `is_recoverable`: Whether the session can continue after this error
|
||||
/// - `error_code`: Optional categorization code (e.g., "TRANSPORT_PARSE_FAILED")
|
||||
/// - `technical_details`: Optional full technical details (truncated if large)
|
||||
/// - `context`: Optional JSON blob with structured error context for debugging
|
||||
SessionError {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
error_message: String,
|
||||
/// Whether the session can continue after this error.
|
||||
/// If false, the session should be considered terminated.
|
||||
is_recoverable: bool,
|
||||
/// Error categorization code for UI grouping and filtering.
|
||||
/// Examples: "TRANSPORT_PARSE_FAILED", "SESSION_NOT_FOUND", "TIMEOUT"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<String>,
|
||||
/// Full technical details including stack traces, received content, etc.
|
||||
/// May be truncated if the original content was very large.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
technical_details: Option<String>,
|
||||
/// Structured error context for debug view (JSON blob).
|
||||
/// Contains machine-readable error information.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
context: Option<serde_json::Value>,
|
||||
},
|
||||
/// Session was transferred from one connector to another
|
||||
///
|
||||
/// Emitted by CoreRuntime when a session transfer completes successfully.
|
||||
/// ACP Server should update client mappings on receiving this event.
|
||||
SessionTransferred {
|
||||
/// Source connector ID (where transfer originated)
|
||||
from_connector: String,
|
||||
/// Source session ID
|
||||
from_session: String,
|
||||
/// Target connector ID (where session is now active)
|
||||
to_connector: String,
|
||||
/// New session ID in target connector
|
||||
to_session: String,
|
||||
/// Whether a new session was created (true) or existing loaded (false)
|
||||
is_new_session: bool,
|
||||
/// Available models and current model from the new connector (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
models: Option<SessionModelState>,
|
||||
/// Available modes and current mode from the new connector (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
modes: Option<SessionModeState>,
|
||||
},
|
||||
/// Emitted by archivist when a session registration is durable and list-stable.
|
||||
///
|
||||
/// Frontend can use this to refresh the session list with confidence that
|
||||
/// the session will appear (it's been written to the archive index).
|
||||
/// This replaces any timeout-based delay hacks after session creation.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// - `connector_id`: The connector that owns this session
|
||||
/// - `session_id`: The native session ID from the connector
|
||||
/// - `scroll_id`: The archivist's canonical scroll_id for this session
|
||||
SessionRegistered {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
/// The archivist's canonical scroll_id for this session
|
||||
scroll_id: String,
|
||||
},
|
||||
|
||||
/// A forwarded session encountered a failure
|
||||
///
|
||||
/// Emitted when a connector that received a transferred session fails.
|
||||
/// Clients should be routed back to Gateway automatically.
|
||||
ForwardingPanic {
|
||||
/// Connector that failed
|
||||
connector_id: String,
|
||||
/// Session that was affected
|
||||
session_id: String,
|
||||
/// Human-readable reason for the failure
|
||||
reason: String,
|
||||
/// ID of the Gateway session to fall back to (if available)
|
||||
fallback_gateway_session: Option<String>,
|
||||
},
|
||||
/// New ACP-style session update (replaces MessagePartAdded for new consumers)
|
||||
SessionUpdate {
|
||||
connector_id: String,
|
||||
session_id: String,
|
||||
update: SessionUpdate,
|
||||
},
|
||||
/// Agent-initiated request requiring client response (e.g., permission prompt)
|
||||
///
|
||||
/// Emitted when an agent sends a request (like session/request_permission) that
|
||||
/// requires user input. The client should respond via the appropriate API endpoint.
|
||||
///
|
||||
/// # Permission Flow Routing
|
||||
///
|
||||
/// - `is_forwarded: false` - Internal session (UI-owned) → Show modal in web UI
|
||||
/// - `is_forwarded: true` - External session (ACP client-owned) → Forward to EventBridge, DO NOT show UI modal
|
||||
///
|
||||
/// The `is_forwarded` field determines whether this permission request should be handled
|
||||
/// by the Dirigent web UI or forwarded to an external ACP client that owns the session.
|
||||
AgentRequest {
|
||||
/// ID of the connector that received the request
|
||||
connector_id: String,
|
||||
/// Session ID from the request parameters
|
||||
session_id: String,
|
||||
/// Request ID from the agent (for correlating the response)
|
||||
request_id: serde_json::Value,
|
||||
/// Method being requested (e.g., "session/request_permission")
|
||||
method: String,
|
||||
/// Request parameters from the agent
|
||||
params: serde_json::Value,
|
||||
/// Whether this is a forwarded (external) session.
|
||||
///
|
||||
/// If `true`, the UI MUST NOT show a permission modal. Instead, the EventBridge
|
||||
/// should forward this request to the external ACP client that owns the session.
|
||||
///
|
||||
/// If `false`, this is an internal session and the UI should show the permission modal.
|
||||
is_forwarded: bool,
|
||||
},
|
||||
|
||||
// ACP Client Connection Events (for UI visibility of incoming connections)
|
||||
/// An ACP client has connected to the server
|
||||
///
|
||||
/// Emitted when a new client connects via the ACP Server.
|
||||
/// Used by UI to show incoming connections in the sidebar.
|
||||
AcpClientConnected {
|
||||
/// Unique client identifier (UUID7)
|
||||
client_id: String,
|
||||
/// When the client connected (ISO 8601 timestamp)
|
||||
connected_at: String,
|
||||
/// Optional client capabilities from the initialize handshake
|
||||
capabilities: Option<serde_json::Value>,
|
||||
/// The Acceptor connector's UID (for archivist meta session creation)
|
||||
connector_uid: String,
|
||||
},
|
||||
/// An ACP client has disconnected from the server
|
||||
///
|
||||
/// Emitted when a client disconnects (explicitly or due to connection loss).
|
||||
/// The client record should be marked as disconnected, not removed (for history).
|
||||
AcpClientDisconnected {
|
||||
/// Unique client identifier
|
||||
client_id: String,
|
||||
/// When the client disconnected (ISO 8601 timestamp)
|
||||
disconnected_at: String,
|
||||
/// Optional reason for disconnection
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// An ACP client has opened a new session via Gateway
|
||||
///
|
||||
/// Emitted when a client creates a new session through the ACP Server.
|
||||
/// This adds an entry to the connection history.
|
||||
AcpClientSessionOpened {
|
||||
/// Client that opened the session
|
||||
client_id: String,
|
||||
/// The Gateway session ID (or initial session before routing)
|
||||
gateway_session_id: String,
|
||||
/// The client-facing session ID
|
||||
client_session_id: String,
|
||||
/// When this occurred (ISO 8601 timestamp)
|
||||
timestamp: String,
|
||||
},
|
||||
/// An ACP client's session was routed to a different connector
|
||||
///
|
||||
/// Emitted when a session is transferred from Gateway to another connector.
|
||||
/// This adds an entry to the connection history showing the route change.
|
||||
AcpClientSessionRouted {
|
||||
/// Client whose session was routed
|
||||
client_id: String,
|
||||
/// Original session ID (typically Gateway session)
|
||||
from_session_id: String,
|
||||
/// New session ID in the target connector
|
||||
to_session_id: String,
|
||||
/// Target connector ID
|
||||
connector_id: String,
|
||||
/// Target connector title (for display)
|
||||
connector_title: String,
|
||||
/// Connector kind (e.g., "opencode", "acp", "gateway")
|
||||
#[serde(default)]
|
||||
connector_kind: Option<String>,
|
||||
/// Current model being used (if known)
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
/// Agent version/name info (if available)
|
||||
#[serde(default)]
|
||||
agent_info: Option<String>,
|
||||
/// When this occurred (ISO 8601 timestamp)
|
||||
timestamp: String,
|
||||
},
|
||||
|
||||
// Message events
|
||||
MessagesListed {
|
||||
messages: Vec<Message>,
|
||||
},
|
||||
MessageStarted {
|
||||
connector_id: String,
|
||||
message: Message,
|
||||
},
|
||||
MessageCompleted {
|
||||
connector_id: String,
|
||||
message: Message,
|
||||
},
|
||||
MessageFailed {
|
||||
message_id: String,
|
||||
error: String,
|
||||
},
|
||||
|
||||
// Connector lifecycle events
|
||||
ConnectorCreated {
|
||||
connector_id: String,
|
||||
kind: String,
|
||||
title: String,
|
||||
},
|
||||
ConnectorRemoved {
|
||||
connector_id: String,
|
||||
},
|
||||
ConnectorStateChanged {
|
||||
connector_id: String,
|
||||
state: String,
|
||||
/// Machine-readable error classification ("offline", "unstable", "connection_failed")
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
error_kind: Option<String>,
|
||||
},
|
||||
|
||||
// System events
|
||||
Connected,
|
||||
Disconnected,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
|
||||
// Inspector events (runtime tree visualization)
|
||||
/// Full snapshot of the inspector tree — sent on initial connection
|
||||
/// and can be requested via server function.
|
||||
InspectorSnapshot {
|
||||
/// ISO 8601 timestamp of the snapshot
|
||||
timestamp: String,
|
||||
/// All nodes in the tree
|
||||
nodes: Vec<InspectorSnapshotNode>,
|
||||
/// Total node count
|
||||
node_count: usize,
|
||||
},
|
||||
/// A new node was registered in the inspector tree
|
||||
InspectorNodeRegistered {
|
||||
id: String,
|
||||
parent: String,
|
||||
kind: String,
|
||||
},
|
||||
/// A node was removed from the inspector tree
|
||||
InspectorNodeRemoved {
|
||||
id: String,
|
||||
},
|
||||
/// A node's lifecycle state changed
|
||||
InspectorStateChanged {
|
||||
id: String,
|
||||
old: String,
|
||||
new: String,
|
||||
},
|
||||
/// A node's properties were updated
|
||||
InspectorPropertiesUpdated {
|
||||
id: String,
|
||||
keys: Vec<String>,
|
||||
},
|
||||
|
||||
// System task events
|
||||
/// A background system task changed status (completed, failed, cancelled).
|
||||
///
|
||||
/// Emitted by the SystemTaskRegistry when a task reaches a terminal state.
|
||||
/// Allows the UI to react to task completion without polling.
|
||||
SystemTaskStatusChanged {
|
||||
/// Unique task identifier (UUIDv7)
|
||||
task_id: String,
|
||||
/// What kind of operation (e.g., "ClaudeImport")
|
||||
kind: String,
|
||||
/// Terminal status: "completed", "failed", or "cancelled"
|
||||
status: String,
|
||||
/// JSON result payload (present when status == "completed")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result_json: Option<String>,
|
||||
/// Error message (present when status == "failed")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::ContentBlock;
|
||||
|
||||
#[test]
|
||||
fn test_session_update_variant_serialization() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_123".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello from event".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let event = Event::SessionUpdate {
|
||||
connector_id: "conn_123".to_string(),
|
||||
session_id: "session_456".to_string(),
|
||||
update,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""event":"SessionUpdate"#));
|
||||
assert!(json.contains(r#""session_id":"session_456"#));
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_123"#));
|
||||
assert!(json.contains(r#""text":"Hello from event"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_variant_deserialization() {
|
||||
let json = r#"{
|
||||
"event": "SessionUpdate",
|
||||
"data": {
|
||||
"connector_id": "conn_123",
|
||||
"session_id": "session_789",
|
||||
"update": {
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_789",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Agent response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let event: Event = serde_json::from_str(json).unwrap();
|
||||
match event {
|
||||
Event::SessionUpdate {
|
||||
connector_id,
|
||||
session_id,
|
||||
update,
|
||||
} => {
|
||||
assert_eq!(connector_id, "conn_123");
|
||||
assert_eq!(session_id, "session_789");
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_789");
|
||||
assert_eq!(
|
||||
content,
|
||||
ContentBlock::Text {
|
||||
text: "Agent response".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(_meta, None);
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected SessionUpdate event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_variant_roundtrip() {
|
||||
let original = Event::SessionUpdate {
|
||||
connector_id: "conn_roundtrip".to_string(),
|
||||
session_id: "session_roundtrip".to_string(),
|
||||
update: SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Thinking...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match (&original, &deserialized) {
|
||||
(
|
||||
Event::SessionUpdate {
|
||||
connector_id: cid1,
|
||||
session_id: sid1,
|
||||
update: update1,
|
||||
},
|
||||
Event::SessionUpdate {
|
||||
connector_id: cid2,
|
||||
session_id: sid2,
|
||||
update: update2,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(cid1, cid2);
|
||||
assert_eq!(sid1, sid2);
|
||||
assert_eq!(update1, update2);
|
||||
}
|
||||
_ => panic!("Roundtrip failed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_events_still_work() {
|
||||
// Verify that existing event variants are not affected
|
||||
use crate::SessionMetadata;
|
||||
use chrono::Utc;
|
||||
|
||||
let now = Utc::now();
|
||||
let session_created = Event::SessionCreated {
|
||||
connector_id: "conn_test".to_string(),
|
||||
session: Session {
|
||||
id: "session_123".to_string(),
|
||||
title: "Test Session".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: SessionMetadata {
|
||||
project_path: "/test".to_string(),
|
||||
model: Some("gpt-4".to_string()),
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&session_created).unwrap();
|
||||
let _deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_error_serialization() {
|
||||
let event = Event::SessionError {
|
||||
connector_id: "acp_conn_1".to_string(),
|
||||
session_id: "session_456".to_string(),
|
||||
error_message: "Session not found".to_string(),
|
||||
is_recoverable: false,
|
||||
error_code: None,
|
||||
technical_details: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""event":"SessionError"#));
|
||||
assert!(json.contains(r#""connector_id":"acp_conn_1"#));
|
||||
assert!(json.contains(r#""session_id":"session_456"#));
|
||||
assert!(json.contains(r#""error_message":"Session not found"#));
|
||||
assert!(json.contains(r#""is_recoverable":false"#));
|
||||
// Optional fields should not be present when None
|
||||
assert!(!json.contains(r#""error_code"#));
|
||||
assert!(!json.contains(r#""technical_details"#));
|
||||
assert!(!json.contains(r#""context"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_error_with_details_serialization() {
|
||||
let event = Event::SessionError {
|
||||
connector_id: "acp_conn_1".to_string(),
|
||||
session_id: "session_456".to_string(),
|
||||
error_message: "Transport parse failed".to_string(),
|
||||
is_recoverable: true,
|
||||
error_code: Some("TRANSPORT_PARSE_FAILED".to_string()),
|
||||
technical_details: Some("Failed to parse JSON: expected value at line 1".to_string()),
|
||||
context: Some(serde_json::json!({
|
||||
"received_bytes": 1024,
|
||||
"received_preview": "option { key: ...",
|
||||
"expected": "JSON-RPC message"
|
||||
})),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""error_code":"TRANSPORT_PARSE_FAILED"#));
|
||||
assert!(json.contains(r#""technical_details"#));
|
||||
assert!(json.contains(r#""context"#));
|
||||
assert!(json.contains(r#""received_bytes":1024"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_error_deserialization() {
|
||||
// Test backward compatibility - old format without new fields
|
||||
let json = r#"{
|
||||
"event": "SessionError",
|
||||
"data": {
|
||||
"connector_id": "conn_test",
|
||||
"session_id": "session_789",
|
||||
"error_message": "Connection timeout",
|
||||
"is_recoverable": true
|
||||
}
|
||||
}"#;
|
||||
|
||||
let event: Event = serde_json::from_str(json).unwrap();
|
||||
match event {
|
||||
Event::SessionError {
|
||||
connector_id,
|
||||
session_id,
|
||||
error_message,
|
||||
is_recoverable,
|
||||
error_code,
|
||||
technical_details,
|
||||
context,
|
||||
} => {
|
||||
assert_eq!(connector_id, "conn_test");
|
||||
assert_eq!(session_id, "session_789");
|
||||
assert_eq!(error_message, "Connection timeout");
|
||||
assert!(is_recoverable);
|
||||
assert!(error_code.is_none());
|
||||
assert!(technical_details.is_none());
|
||||
assert!(context.is_none());
|
||||
}
|
||||
_ => panic!("Expected SessionError event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_error_roundtrip() {
|
||||
let original = Event::SessionError {
|
||||
connector_id: "roundtrip_conn".to_string(),
|
||||
session_id: "roundtrip_session".to_string(),
|
||||
error_message: "API rate limit exceeded".to_string(),
|
||||
is_recoverable: true,
|
||||
error_code: Some("RATE_LIMITED".to_string()),
|
||||
technical_details: Some("429 Too Many Requests".to_string()),
|
||||
context: Some(serde_json::json!({"retry_after": 60})),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match (&original, &deserialized) {
|
||||
(
|
||||
Event::SessionError {
|
||||
connector_id: cid1,
|
||||
session_id: sid1,
|
||||
error_message: err1,
|
||||
is_recoverable: rec1,
|
||||
error_code: code1,
|
||||
technical_details: details1,
|
||||
context: ctx1,
|
||||
},
|
||||
Event::SessionError {
|
||||
connector_id: cid2,
|
||||
session_id: sid2,
|
||||
error_message: err2,
|
||||
is_recoverable: rec2,
|
||||
error_code: code2,
|
||||
technical_details: details2,
|
||||
context: ctx2,
|
||||
},
|
||||
) => {
|
||||
assert_eq!(cid1, cid2);
|
||||
assert_eq!(sid1, sid2);
|
||||
assert_eq!(code1, code2);
|
||||
assert_eq!(details1, details2);
|
||||
assert_eq!(ctx1, ctx2);
|
||||
assert_eq!(err1, err2);
|
||||
assert_eq!(rec1, rec2);
|
||||
}
|
||||
_ => panic!("Roundtrip failed"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_transferred_serialization() {
|
||||
let event = Event::SessionTransferred {
|
||||
from_connector: "gateway-1".to_string(),
|
||||
from_session: "session-old".to_string(),
|
||||
to_connector: "opencode-1".to_string(),
|
||||
to_session: "session-new".to_string(),
|
||||
is_new_session: true,
|
||||
models: None,
|
||||
modes: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""event":"SessionTransferred"#));
|
||||
assert!(json.contains(r#""from_connector":"gateway-1"#));
|
||||
assert!(json.contains(r#""to_connector":"opencode-1"#));
|
||||
|
||||
let deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
// Verify roundtrip
|
||||
match deserialized {
|
||||
Event::SessionTransferred {
|
||||
from_connector,
|
||||
from_session,
|
||||
to_connector,
|
||||
to_session,
|
||||
is_new_session,
|
||||
models,
|
||||
modes,
|
||||
} => {
|
||||
assert_eq!(from_connector, "gateway-1");
|
||||
assert_eq!(from_session, "session-old");
|
||||
assert_eq!(to_connector, "opencode-1");
|
||||
assert_eq!(to_session, "session-new");
|
||||
assert!(is_new_session);
|
||||
assert!(models.is_none());
|
||||
assert!(modes.is_none());
|
||||
}
|
||||
_ => panic!("Expected SessionTransferred event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forwarding_panic_serialization() {
|
||||
let event = Event::ForwardingPanic {
|
||||
connector_id: "opencode-1".to_string(),
|
||||
session_id: "session-123".to_string(),
|
||||
reason: "Connection lost".to_string(),
|
||||
fallback_gateway_session: Some("gateway-session-1".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""event":"ForwardingPanic"#));
|
||||
assert!(json.contains(r#""connector_id":"opencode-1"#));
|
||||
assert!(json.contains(r#""session_id":"session-123"#));
|
||||
assert!(json.contains(r#""reason":"Connection lost"#));
|
||||
|
||||
let deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
// Verify roundtrip
|
||||
match deserialized {
|
||||
Event::ForwardingPanic {
|
||||
connector_id,
|
||||
session_id,
|
||||
reason,
|
||||
fallback_gateway_session,
|
||||
} => {
|
||||
assert_eq!(connector_id, "opencode-1");
|
||||
assert_eq!(session_id, "session-123");
|
||||
assert_eq!(reason, "Connection lost");
|
||||
assert_eq!(
|
||||
fallback_gateway_session,
|
||||
Some("gateway-session-1".to_string())
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected ForwardingPanic event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_received_full() {
|
||||
use crate::session::{ModelInfo, SessionMode, SessionModeState, SessionModelState};
|
||||
|
||||
let event = Event::SessionMetadataReceived {
|
||||
connector_id: "claude-acp-1".to_string(),
|
||||
session_id: "session-123".to_string(),
|
||||
models: Some(SessionModelState {
|
||||
available_models: vec![
|
||||
ModelInfo {
|
||||
model_id: "default".to_string(),
|
||||
name: "Default (recommended)".to_string(),
|
||||
description: Some("Opus 4.5".to_string()),
|
||||
},
|
||||
ModelInfo {
|
||||
model_id: "sonnet".to_string(),
|
||||
name: "Sonnet".to_string(),
|
||||
description: None,
|
||||
},
|
||||
],
|
||||
current_model_id: "default".to_string(),
|
||||
}),
|
||||
modes: Some(SessionModeState {
|
||||
current_mode_id: "default".to_string(),
|
||||
available_modes: vec![
|
||||
SessionMode {
|
||||
id: "default".to_string(),
|
||||
name: "Always Ask".to_string(),
|
||||
description: Some("Prompts for permission".to_string()),
|
||||
},
|
||||
SessionMode {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan Mode".to_string(),
|
||||
description: None,
|
||||
},
|
||||
],
|
||||
}),
|
||||
config_options: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""event":"SessionMetadataReceived"#));
|
||||
assert!(json.contains(r#""connector_id":"claude-acp-1"#));
|
||||
assert!(json.contains(r#""session_id":"session-123"#));
|
||||
// Check camelCase in nested types
|
||||
assert!(json.contains("availableModels"));
|
||||
assert!(json.contains("currentModelId"));
|
||||
assert!(json.contains("availableModes"));
|
||||
assert!(json.contains("currentModeId"));
|
||||
|
||||
let deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
Event::SessionMetadataReceived {
|
||||
connector_id,
|
||||
session_id,
|
||||
models,
|
||||
modes,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(connector_id, "claude-acp-1");
|
||||
assert_eq!(session_id, "session-123");
|
||||
assert!(models.is_some());
|
||||
assert!(modes.is_some());
|
||||
let models = models.unwrap();
|
||||
assert_eq!(models.current_model_id, "default");
|
||||
assert_eq!(models.available_models.len(), 2);
|
||||
let modes = modes.unwrap();
|
||||
assert_eq!(modes.current_mode_id, "default");
|
||||
assert_eq!(modes.available_modes.len(), 2);
|
||||
}
|
||||
_ => panic!("Expected SessionMetadataReceived event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_received_partial() {
|
||||
// Test with only modes (models is None)
|
||||
use crate::session::{SessionMode, SessionModeState};
|
||||
|
||||
let event = Event::SessionMetadataReceived {
|
||||
connector_id: "gateway-1".to_string(),
|
||||
session_id: "session-456".to_string(),
|
||||
models: None,
|
||||
modes: Some(SessionModeState {
|
||||
current_mode_id: "default".to_string(),
|
||||
available_modes: vec![SessionMode {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
description: None,
|
||||
}],
|
||||
}),
|
||||
config_options: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
// models should be skipped when None
|
||||
assert!(!json.contains("availableModels"));
|
||||
assert!(json.contains("availableModes"));
|
||||
|
||||
let deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
Event::SessionMetadataReceived { models, modes, .. } => {
|
||||
assert!(models.is_none());
|
||||
assert!(modes.is_some());
|
||||
}
|
||||
_ => panic!("Expected SessionMetadataReceived event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_received_empty() {
|
||||
// Test with both None (connector provides no metadata)
|
||||
let event = Event::SessionMetadataReceived {
|
||||
connector_id: "generic-1".to_string(),
|
||||
session_id: "session-789".to_string(),
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
// Both should be skipped when None
|
||||
assert!(!json.contains("models"));
|
||||
assert!(!json.contains("modes"));
|
||||
|
||||
let deserialized: Event = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
Event::SessionMetadataReceived { models, modes, .. } => {
|
||||
assert!(models.is_none());
|
||||
assert!(modes.is_none());
|
||||
}
|
||||
_ => panic!("Expected SessionMetadataReceived event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Inspector node types for the process tree.
|
||||
//!
|
||||
//! These types represent nodes in the inspector's hierarchical process tree,
|
||||
//! providing a canonical definition that can be shared between the server-side
|
||||
//! inspector and WASM-based UI.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
/// Hierarchical identifier for a node in the inspector tree.
|
||||
///
|
||||
/// Uses `/`-separated segments (e.g., `"root/connector-1/process-a"`).
|
||||
#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NodeId(pub String);
|
||||
|
||||
impl NodeId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
|
||||
/// Create a child node ID by appending a segment.
|
||||
pub fn child(&self, segment: &str) -> Self {
|
||||
Self(format!("{}/{}", self.0, segment))
|
||||
}
|
||||
|
||||
/// Get the parent node ID (everything before the last `/`).
|
||||
pub fn parent(&self) -> Option<Self> {
|
||||
self.0.rfind('/').map(|idx| Self(self.0[..idx].to_string()))
|
||||
}
|
||||
|
||||
/// Get the last segment of the path (the node's own name).
|
||||
pub fn name(&self) -> &str {
|
||||
self.0.rsplit('/').next().unwrap_or(&self.0)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NodeId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for NodeId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for NodeId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of node in the inspector tree.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum NodeKind {
|
||||
Root,
|
||||
Connector,
|
||||
Process,
|
||||
Service,
|
||||
AsyncTask,
|
||||
System,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for NodeKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
NodeKind::Root => write!(f, "Root"),
|
||||
NodeKind::Connector => write!(f, "Connector"),
|
||||
NodeKind::Process => write!(f, "Process"),
|
||||
NodeKind::Service => write!(f, "Service"),
|
||||
NodeKind::AsyncTask => write!(f, "AsyncTask"),
|
||||
NodeKind::System => write!(f, "System"),
|
||||
NodeKind::Custom(name) => write!(f, "Custom({})", name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The runtime state of an inspector node.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum NodeState {
|
||||
Initializing,
|
||||
Running,
|
||||
Idle,
|
||||
Busy(String),
|
||||
Degraded(String),
|
||||
Error(String),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl fmt::Display for NodeState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
NodeState::Initializing => write!(f, "Initializing"),
|
||||
NodeState::Running => write!(f, "Running"),
|
||||
NodeState::Idle => write!(f, "Idle"),
|
||||
NodeState::Busy(desc) => write!(f, "Busy({})", desc),
|
||||
NodeState::Degraded(reason) => write!(f, "Degraded({})", reason),
|
||||
NodeState::Error(msg) => write!(f, "Error({})", msg),
|
||||
NodeState::Stopped => write!(f, "Stopped"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata associated with an inspector node.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct NodeMetadata {
|
||||
pub kind: NodeKind,
|
||||
pub label: String,
|
||||
pub state: NodeState,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_updated: chrono::DateTime<chrono::Utc>,
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl NodeMetadata {
|
||||
pub fn new(kind: NodeKind, label: impl Into<String>) -> Self {
|
||||
let now = chrono::Utc::now();
|
||||
Self {
|
||||
kind,
|
||||
label: label.into(),
|
||||
state: NodeState::Initializing,
|
||||
created_at: now,
|
||||
last_updated: now,
|
||||
properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_state(mut self, state: NodeState) -> Self {
|
||||
self.state = state;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_property(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
|
||||
self.properties.insert(key.into(), value);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
pub mod accumulator;
|
||||
pub mod adapters;
|
||||
pub mod conversation;
|
||||
pub mod events;
|
||||
pub mod inspector;
|
||||
pub mod log_utils;
|
||||
pub mod project;
|
||||
pub mod session;
|
||||
pub mod sharing;
|
||||
pub mod streaming;
|
||||
pub mod types;
|
||||
|
||||
pub use conversation::{Message, MessageMetadata, MessagePart, MessageRole, MessageStatus};
|
||||
pub use events::{Event, InspectorSnapshotNode, TurnCompleteTrigger};
|
||||
pub use inspector::{NodeId, NodeKind, NodeMetadata, NodeState};
|
||||
pub use session::{
|
||||
ConfigOption, ConfigOptionType, ConfigOptionValue, ModelId, ModelInfo, Session,
|
||||
SessionMetadata, SessionMode, SessionModeId, SessionModeState, SessionModelState,
|
||||
SessionOrigin, SessionOwnership, ToolHandler,
|
||||
};
|
||||
pub use types::{
|
||||
ContentBlock, Meta, PermissionOption, PermissionOptionKind, PermissionToolCallStatus,
|
||||
ProviderMeta, RequestPermissionOutcome, RequestPermissionResponse, SessionUpdate, ToolCall,
|
||||
ToolCallContent, ToolCallId, ToolCallInfo, ToolCallLocation, ToolCallStatus, ToolKind,
|
||||
};
|
||||
pub use sharing::{SessionShare, ShareId, ShareSummary};
|
||||
pub use accumulator::{AccumulatedMessage, AccumulatedPart, MessageAccumulator, ToolCallData as AccumulatorToolCallData};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_public_api_imports() {
|
||||
// Test that all types are accessible from the crate root
|
||||
|
||||
// ContentBlock
|
||||
let _content = ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
};
|
||||
|
||||
// Meta and ProviderMeta
|
||||
let _meta = Meta::default();
|
||||
let _provider = ProviderMeta {
|
||||
name: "test".to_string(),
|
||||
original_ids: None,
|
||||
raw_excerpt: None,
|
||||
};
|
||||
|
||||
// ToolCall, ToolCallId, ToolCallStatus
|
||||
let _tool_call_id: ToolCallId = "call_123".to_string();
|
||||
let _status = ToolCallStatus::Pending;
|
||||
let _tool_call = ToolCall {
|
||||
id: "call_123".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
// SessionUpdate
|
||||
let _update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_123".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// If this compiles, all types are accessible via use dirigent_protocol::{...}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_types_accessible() {
|
||||
// Test that new ACP session metadata types are accessible from crate root
|
||||
|
||||
// ModelId and SessionModeId (type aliases)
|
||||
let _model_id: ModelId = "default".to_string();
|
||||
let _mode_id: SessionModeId = "plan".to_string();
|
||||
|
||||
// ModelInfo
|
||||
let _model_info = ModelInfo {
|
||||
model_id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
description: Some("Main model".to_string()),
|
||||
};
|
||||
|
||||
// SessionModelState
|
||||
let _model_state = SessionModelState {
|
||||
available_models: vec![_model_info],
|
||||
current_model_id: "default".to_string(),
|
||||
};
|
||||
|
||||
// SessionMode
|
||||
let _mode = SessionMode {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
description: None,
|
||||
};
|
||||
|
||||
// SessionModeState
|
||||
let _mode_state = SessionModeState {
|
||||
current_mode_id: "default".to_string(),
|
||||
available_modes: vec![_mode],
|
||||
};
|
||||
|
||||
// SessionMetadataReceived event
|
||||
let _event = Event::SessionMetadataReceived {
|
||||
connector_id: "test".to_string(),
|
||||
session_id: "test".to_string(),
|
||||
models: Some(_model_state),
|
||||
modes: Some(_mode_state),
|
||||
config_options: None,
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permission_types_accessible() {
|
||||
// Test that ACP permission types are accessible from crate root
|
||||
|
||||
// PermissionOption and PermissionOptionKind
|
||||
let _option = PermissionOption {
|
||||
option_id: "allow_1".to_string(),
|
||||
name: "Allow once".to_string(),
|
||||
kind: PermissionOptionKind::AllowOnce,
|
||||
};
|
||||
|
||||
// RequestPermissionResponse and RequestPermissionOutcome
|
||||
let _response = RequestPermissionResponse {
|
||||
outcome: RequestPermissionOutcome::Selected {
|
||||
option_id: "allow_1".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
// ToolCallInfo and related types
|
||||
let _info = ToolCallInfo {
|
||||
tool_call_id: "call_123".to_string(),
|
||||
title: "Read file".to_string(),
|
||||
kind: Some(ToolKind::Read),
|
||||
status: Some(PermissionToolCallStatus::Pending),
|
||||
locations: Some(vec![ToolCallLocation {
|
||||
path: "/test.txt".to_string(),
|
||||
line: Some(10),
|
||||
}]),
|
||||
raw_input: None,
|
||||
};
|
||||
|
||||
// If this compiles, all permission types are accessible
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_ownership_types_accessible() {
|
||||
// Test that Session Ownership Model types are accessible from crate root
|
||||
|
||||
// SessionOrigin
|
||||
let _origin_internal = SessionOrigin::Internal;
|
||||
let _origin_external = SessionOrigin::External {
|
||||
client_id: "test".to_string(),
|
||||
client_capabilities: None,
|
||||
};
|
||||
|
||||
// ToolHandler
|
||||
let _handler_agent = ToolHandler::Agent;
|
||||
let _handler_dirigent = ToolHandler::Dirigent;
|
||||
let _handler_forward = ToolHandler::ForwardToClient;
|
||||
|
||||
// SessionOwnership and its constructors
|
||||
let _ownership_default = SessionOwnership::default();
|
||||
let _ownership_internal = SessionOwnership::internal();
|
||||
let _ownership_forwarded =
|
||||
SessionOwnership::external_forwarded("client-123".to_string(), None);
|
||||
let _ownership_handled = SessionOwnership::external_handled("client-456".to_string(), None);
|
||||
|
||||
// Test helper methods
|
||||
assert!(!_ownership_internal.is_external());
|
||||
assert!(_ownership_forwarded.is_external());
|
||||
assert_eq!(_ownership_internal.client_id(), None);
|
||||
assert_eq!(_ownership_forwarded.client_id(), Some("client-123"));
|
||||
|
||||
// If this compiles, all ownership types are accessible
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/// Utilities for masking sensitive or verbose content in logs
|
||||
use serde_json::Value;
|
||||
|
||||
/// Truncate long strings to a reasonable length for logging
|
||||
const MAX_LOG_LENGTH: usize = 100;
|
||||
|
||||
/// Extract filename from a file path (handles both Unix and Windows paths)
|
||||
fn extract_filename(path: &str) -> &str {
|
||||
// Try Unix-style path separator first, then Windows
|
||||
if path.contains('/') {
|
||||
path.split('/').last().unwrap_or("file")
|
||||
} else if path.contains('\\') {
|
||||
path.split('\\').last().unwrap_or("file")
|
||||
} else {
|
||||
// No path separator, just a filename
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Mask text content in JSON values for concise logging
|
||||
///
|
||||
/// This function recursively processes JSON and replaces long text fields
|
||||
/// with truncated versions or placeholders, while preserving structure
|
||||
/// and non-text metadata.
|
||||
pub fn mask_content(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if s.len() > MAX_LOG_LENGTH {
|
||||
Value::String(format!("... ({} chars)", s.len()))
|
||||
} else {
|
||||
Value::String(s.clone())
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
Value::Array(arr.iter().map(mask_content).collect())
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
let mut masked = serde_json::Map::new();
|
||||
for (k, v) in obj {
|
||||
// Mask known content fields (only if they're strings)
|
||||
if k == "text" || k == "content_md" || k == "message" || k == "thinking" {
|
||||
if let Value::String(s) = v {
|
||||
if s.len() > 50 {
|
||||
masked.insert(k.clone(), Value::String(format!("... ({} chars)", s.len())));
|
||||
} else if s.len() > 0 {
|
||||
masked.insert(k.clone(), Value::String("...".to_string()));
|
||||
} else {
|
||||
masked.insert(k.clone(), Value::String("".to_string()));
|
||||
}
|
||||
} else {
|
||||
// Not a string, recurse (e.g., content as object)
|
||||
masked.insert(k.clone(), mask_content(v));
|
||||
}
|
||||
}
|
||||
// Mask raw input/output fields (large blobs of data)
|
||||
else if k == "rawOutput" || k == "rawInput" || k == "raw_output" || k == "raw_input" {
|
||||
if let Value::String(s) = v {
|
||||
if s.len() > 50 {
|
||||
masked.insert(k.clone(), Value::String(format!("... ({} chars)", s.len())));
|
||||
} else if s.len() > 0 {
|
||||
masked.insert(k.clone(), Value::String("...".to_string()));
|
||||
} else {
|
||||
masked.insert(k.clone(), Value::String("".to_string()));
|
||||
}
|
||||
} else {
|
||||
// Not a string, recurse
|
||||
masked.insert(k.clone(), mask_content(v));
|
||||
}
|
||||
}
|
||||
// Mask filename/path fields
|
||||
else if k == "filename" || k == "file" || k == "path" || k == "file_path" || k == "filepath" {
|
||||
if let Value::String(s) = v {
|
||||
// Extract just the filename from path for debugging context
|
||||
let filename = extract_filename(s);
|
||||
masked.insert(k.clone(), Value::String(format!("<{}>", filename)));
|
||||
} else if let Value::Array(arr) = v {
|
||||
// Array of filenames
|
||||
let masked_arr: Vec<Value> = arr.iter().map(|item| {
|
||||
if let Value::String(s) = item {
|
||||
let filename = extract_filename(s);
|
||||
Value::String(format!("<{}>", filename))
|
||||
} else {
|
||||
item.clone()
|
||||
}
|
||||
}).collect();
|
||||
masked.insert(k.clone(), Value::Array(masked_arr));
|
||||
} else {
|
||||
masked.insert(k.clone(), mask_content(v));
|
||||
}
|
||||
}
|
||||
// Mask arrays of filenames
|
||||
else if k == "filenames" || k == "files" || k == "paths" {
|
||||
if let Value::Array(arr) = v {
|
||||
let masked_arr: Vec<Value> = arr.iter().map(|item| {
|
||||
if let Value::String(s) = item {
|
||||
let filename = extract_filename(s);
|
||||
Value::String(format!("<{}>", filename))
|
||||
} else {
|
||||
mask_content(item)
|
||||
}
|
||||
}).collect();
|
||||
masked.insert(k.clone(), Value::Array(masked_arr));
|
||||
} else {
|
||||
masked.insert(k.clone(), mask_content(v));
|
||||
}
|
||||
}
|
||||
else {
|
||||
masked.insert(k.clone(), mask_content(v));
|
||||
}
|
||||
}
|
||||
Value::Object(masked)
|
||||
}
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a Value for logging with content masked
|
||||
pub fn format_for_log(value: &Value) -> String {
|
||||
let masked = mask_content(value);
|
||||
serde_json::to_string(&masked).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
/// Mask content in a JSON string, returning a masked JSON string
|
||||
pub fn mask_json_string(json_str: &str) -> String {
|
||||
match serde_json::from_str::<Value>(json_str) {
|
||||
Ok(value) => format_for_log(&value),
|
||||
Err(_) => {
|
||||
// If not valid JSON, just truncate
|
||||
if json_str.len() > MAX_LOG_LENGTH {
|
||||
format!("{}... ({} bytes)", &json_str[..MAX_LOG_LENGTH], json_str.len())
|
||||
} else {
|
||||
json_str.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_mask_short_string() {
|
||||
let value = Value::String("short".to_string());
|
||||
let masked = mask_content(&value);
|
||||
assert_eq!(masked, Value::String("short".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_long_string() {
|
||||
let long_str = "a".repeat(200);
|
||||
let value = Value::String(long_str.clone());
|
||||
let masked = mask_content(&value);
|
||||
if let Value::String(s) = masked {
|
||||
assert!(s.contains("(200 chars)"));
|
||||
assert!(s.len() < long_str.len());
|
||||
} else {
|
||||
panic!("Expected string");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_text_field() {
|
||||
let value = json!({
|
||||
"text": "This is some long message content that should be masked",
|
||||
"message_id": "123",
|
||||
"role": "user"
|
||||
});
|
||||
let masked = mask_content(&value);
|
||||
assert_eq!(masked["message_id"], "123");
|
||||
assert_eq!(masked["role"], "user");
|
||||
// Text is 56 chars, which is > 50, so should be masked with char count
|
||||
assert_eq!(masked["text"], "... (55 chars)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_nested_content() {
|
||||
let value = json!({
|
||||
"sessionId": "abc-123",
|
||||
"update": {
|
||||
"content": [{
|
||||
"type": "content",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "A".repeat(500)
|
||||
}
|
||||
}],
|
||||
"sessionUpdate": "tool_call_update",
|
||||
"status": "completed"
|
||||
}
|
||||
});
|
||||
let masked = mask_content(&value);
|
||||
assert_eq!(masked["sessionId"], "abc-123");
|
||||
assert_eq!(masked["update"]["sessionUpdate"], "tool_call_update");
|
||||
assert_eq!(masked["update"]["status"], "completed");
|
||||
|
||||
// Check that the text field is masked
|
||||
let text_value = &masked["update"]["content"][0]["content"]["text"];
|
||||
if let Value::String(s) = text_value {
|
||||
assert!(s.contains("(500 chars)"));
|
||||
} else {
|
||||
panic!("Expected text to be masked");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_content_as_object() {
|
||||
// Ensure "content" as object is NOT masked, only strings
|
||||
let value = json!({
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Some message"
|
||||
},
|
||||
"message_id": "123"
|
||||
});
|
||||
let masked = mask_content(&value);
|
||||
assert_eq!(masked["message_id"], "123");
|
||||
assert_eq!(masked["content"]["type"], "text");
|
||||
assert_eq!(masked["content"]["text"], "..."); // text field is masked
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_filename() {
|
||||
let value = json!({
|
||||
"filename": "/Users/name/Projects/dirigent/packages/web/src/main.rs",
|
||||
"operation": "read"
|
||||
});
|
||||
let masked = mask_content(&value);
|
||||
assert_eq!(masked["operation"], "read");
|
||||
assert_eq!(masked["filename"], "<main.rs>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mask_filenames_array() {
|
||||
let value = json!({
|
||||
"filenames": [
|
||||
"/Users/name/Projects/dirigent/packages/web/src/main.rs",
|
||||
"/Users/name/Projects/dirigent/packages/api/src/core.rs",
|
||||
"C:\\Users\\name\\Documents\\file.txt"
|
||||
],
|
||||
"count": 3
|
||||
});
|
||||
let masked = mask_content(&value);
|
||||
assert_eq!(masked["count"], 3);
|
||||
assert_eq!(masked["filenames"][0], "<main.rs>");
|
||||
assert_eq!(masked["filenames"][1], "<core.rs>");
|
||||
assert_eq!(masked["filenames"][2], "<file.txt>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Project types for the Dirigent system.
|
||||
//!
|
||||
//! WASM-compatible shared types for the Projects module. These types are
|
||||
//! used by both server and client (web UI) code.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A project in the Dirigent system.
|
||||
///
|
||||
/// Projects organize work across repositories, sessions, and connectors.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
/// Unique project identifier (UUID v7)
|
||||
pub id: Uuid,
|
||||
/// Human-readable project name
|
||||
pub name: String,
|
||||
/// Project description (empty by default)
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Optional icon (emoji or abbreviation)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
/// Owner user ID
|
||||
pub owner: Uuid,
|
||||
/// Member user IDs
|
||||
#[serde(default)]
|
||||
pub members: Vec<Uuid>,
|
||||
/// Categorization tags
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Programming languages used
|
||||
#[serde(default)]
|
||||
pub languages: Vec<String>,
|
||||
/// Linked project IDs (for multi-project setups)
|
||||
#[serde(default)]
|
||||
pub linked_projects: Vec<Uuid>,
|
||||
/// Arbitrary metadata
|
||||
#[serde(default = "default_metadata")]
|
||||
pub metadata: serde_json::Value,
|
||||
/// When this project was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When this project was last updated
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_metadata() -> serde_json::Value {
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
}
|
||||
|
||||
/// A local git repository associated with a project.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectRepository {
|
||||
/// Unique repository identifier (UUID v7)
|
||||
pub id: Uuid,
|
||||
/// Project this repository belongs to
|
||||
pub project_id: Uuid,
|
||||
/// Local filesystem path
|
||||
pub path: PathBuf,
|
||||
/// Whether this is the primary repository
|
||||
#[serde(default)]
|
||||
pub is_primary: bool,
|
||||
/// Optional human-readable label
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
/// Access mode
|
||||
#[serde(default)]
|
||||
pub access: AccessMode,
|
||||
/// When this repository was added
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When this repository was last updated
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Repository access mode.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AccessMode {
|
||||
/// Read-only access
|
||||
Read,
|
||||
/// Read and write access
|
||||
#[default]
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
/// A git worktree linked to a repository.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Worktree {
|
||||
/// Unique worktree identifier (UUID v7)
|
||||
pub id: Uuid,
|
||||
/// Repository this worktree belongs to
|
||||
pub repository_id: Uuid,
|
||||
/// Local filesystem path
|
||||
pub path: PathBuf,
|
||||
/// Branch name
|
||||
pub branch: String,
|
||||
/// Optional work branch name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub work_branch: Option<String>,
|
||||
/// Optional naming strategy
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub naming_strategy: Option<String>,
|
||||
/// When this worktree was created
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Binding between a project and a connector/session.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectBinding {
|
||||
/// Unique binding identifier (UUID v7)
|
||||
pub id: Uuid,
|
||||
/// Project this binding belongs to
|
||||
pub project_id: Uuid,
|
||||
/// Optional connector ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub connector_id: Option<String>,
|
||||
/// Optional session ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<Uuid>,
|
||||
/// Optional working directory override
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Runtime git state (not persisted, computed on demand).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct GitState {
|
||||
/// Current branch name
|
||||
pub branch: String,
|
||||
/// Whether there are uncommitted changes
|
||||
#[serde(default)]
|
||||
pub is_dirty: bool,
|
||||
/// Commits ahead of remote
|
||||
#[serde(default)]
|
||||
pub ahead: u32,
|
||||
/// Commits behind remote
|
||||
#[serde(default)]
|
||||
pub behind: u32,
|
||||
/// Remote names
|
||||
#[serde(default)]
|
||||
pub remotes: Vec<String>,
|
||||
/// Active worktrees
|
||||
#[serde(default)]
|
||||
pub worktrees: Vec<WorktreeInfo>,
|
||||
/// Unexpected conditions
|
||||
#[serde(default)]
|
||||
pub unexpected: Vec<GitWarning>,
|
||||
}
|
||||
|
||||
/// Information about an active worktree.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct WorktreeInfo {
|
||||
/// Worktree filesystem path
|
||||
pub path: PathBuf,
|
||||
/// Branch checked out (None if detached)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub branch: Option<String>,
|
||||
/// Whether HEAD is detached
|
||||
#[serde(default)]
|
||||
pub is_detached: bool,
|
||||
}
|
||||
|
||||
/// A warning about unexpected git state.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GitWarning {
|
||||
/// Warning code for programmatic handling
|
||||
pub code: String,
|
||||
/// Human-readable warning message
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_project_serialization_roundtrip() {
|
||||
let now = Utc::now();
|
||||
let project = Project {
|
||||
id: Uuid::now_v7(),
|
||||
name: "Test Project".to_string(),
|
||||
description: "A test project".to_string(),
|
||||
icon: Some("🚀".to_string()),
|
||||
owner: Uuid::now_v7(),
|
||||
members: vec![],
|
||||
tags: vec!["rust".to_string()],
|
||||
languages: vec!["Rust".to_string()],
|
||||
linked_projects: vec![],
|
||||
metadata: serde_json::json!({"key": "value"}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&project).expect("serialize");
|
||||
let deser: Project = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(deser.id, project.id);
|
||||
assert_eq!(deser.name, project.name);
|
||||
assert_eq!(deser.icon, project.icon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_defaults() {
|
||||
let json = r#"{
|
||||
"id": "019504a0-0000-7000-8000-000000000001",
|
||||
"name": "Minimal",
|
||||
"owner": "019504a0-0000-7000-8000-000000000002",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}"#;
|
||||
|
||||
let project: Project = serde_json::from_str(json).expect("deserialize");
|
||||
assert_eq!(project.description, "");
|
||||
assert!(project.tags.is_empty());
|
||||
assert!(project.members.is_empty());
|
||||
assert!(project.metadata.is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_access_mode_default() {
|
||||
assert_eq!(AccessMode::default(), AccessMode::ReadWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_repository_roundtrip() {
|
||||
let now = Utc::now();
|
||||
let repo = ProjectRepository {
|
||||
id: Uuid::now_v7(),
|
||||
project_id: Uuid::now_v7(),
|
||||
path: PathBuf::from("/home/user/project"),
|
||||
is_primary: true,
|
||||
label: Some("main".to_string()),
|
||||
access: AccessMode::ReadWrite,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&repo).expect("serialize");
|
||||
let deser: ProjectRepository = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(deser.id, repo.id);
|
||||
assert!(deser.is_primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_state_default() {
|
||||
let state = GitState::default();
|
||||
assert_eq!(state.branch, "");
|
||||
assert!(!state.is_dirty);
|
||||
assert_eq!(state.ahead, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_roundtrip() {
|
||||
let binding = ProjectBinding {
|
||||
id: Uuid::now_v7(),
|
||||
project_id: Uuid::now_v7(),
|
||||
connector_id: Some("conn-1".to_string()),
|
||||
session_id: None,
|
||||
working_dir: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&binding).expect("serialize");
|
||||
let deser: ProjectBinding = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(deser.id, binding.id);
|
||||
assert!(deser.session_id.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,972 @@
|
||||
use crate::types::meta::Meta;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub metadata: SessionMetadata,
|
||||
/// Working directory for this session (if known)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
/// ACP model state (available models and current model)
|
||||
/// Populated from archivist for archived sessions, or from SSE events for live sessions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub models: Option<SessionModelState>,
|
||||
/// ACP mode state (available modes and current mode)
|
||||
/// Populated from archivist for archived sessions, or from SSE events for live sessions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub modes: Option<SessionModeState>,
|
||||
/// ACP config options (replaces modes/models in future ACP versions)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_options: Option<Vec<ConfigOption>>,
|
||||
/// ACP client ID that owns this session.
|
||||
/// For sessions created via ACP Server (incoming connections), this identifies
|
||||
/// which connected client created/owns this session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub acp_client_id: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACP Session Mode/Model Types
|
||||
// ============================================================================
|
||||
// These types match the Agent-Client Protocol (ACP) specification exactly.
|
||||
// They use camelCase serialization to match Claude-ACP's JSON format.
|
||||
// See: docs/architecture/agent_client_protocol/schema.md
|
||||
|
||||
/// Type alias for session mode identifiers (e.g., "default", "plan", "bypassPermissions")
|
||||
pub type SessionModeId = String;
|
||||
|
||||
/// Type alias for model identifiers (e.g., "default", "sonnet", "haiku", "opus")
|
||||
pub type ModelId = String;
|
||||
|
||||
/// Session mode state from ACP `session/new` response
|
||||
///
|
||||
/// Contains the list of available modes and the currently active mode.
|
||||
/// This is part of the stable ACP specification.
|
||||
///
|
||||
/// # Example (from Claude-ACP)
|
||||
/// ```json
|
||||
/// {
|
||||
/// "currentModeId": "default",
|
||||
/// "availableModes": [
|
||||
/// {"id": "default", "name": "Always Ask", "description": "..."},
|
||||
/// {"id": "plan", "name": "Plan Mode", "description": "..."}
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionModeState {
|
||||
/// The currently active mode ID
|
||||
pub current_mode_id: SessionModeId,
|
||||
/// List of all available modes for this session
|
||||
pub available_modes: Vec<SessionMode>,
|
||||
}
|
||||
|
||||
/// A single session mode definition
|
||||
///
|
||||
/// Modes affect agent behavior, tool availability, and permission handling.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionMode {
|
||||
/// Unique identifier for this mode
|
||||
pub id: SessionModeId,
|
||||
/// Human-readable display name
|
||||
pub name: String,
|
||||
/// Optional description of what this mode does
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Session model state from ACP `session/new` response
|
||||
///
|
||||
/// Contains the list of available models and the currently selected model.
|
||||
/// Note: This field is marked UNSTABLE in the ACP spec but is used by Claude-ACP.
|
||||
///
|
||||
/// # Example (from Claude-ACP)
|
||||
/// ```json
|
||||
/// {
|
||||
/// "availableModels": [
|
||||
/// {"modelId": "default", "name": "Default (recommended)", "description": "..."},
|
||||
/// {"modelId": "sonnet", "name": "Sonnet", "description": "..."}
|
||||
/// ],
|
||||
/// "currentModelId": "default"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionModelState {
|
||||
/// List of all available models for this session
|
||||
pub available_models: Vec<ModelInfo>,
|
||||
/// The currently selected model ID
|
||||
pub current_model_id: ModelId,
|
||||
}
|
||||
|
||||
/// Information about a single model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelInfo {
|
||||
/// Unique identifier for this model
|
||||
pub model_id: ModelId,
|
||||
/// Human-readable display name
|
||||
pub name: String,
|
||||
/// Optional description of the model's capabilities
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ACP Config Options (replaces modes/models in future ACP versions)
|
||||
// ============================================================================
|
||||
|
||||
/// A configuration option for a session (ACP configOptions).
|
||||
///
|
||||
/// Agents provide config options in session/new and session/load responses.
|
||||
/// Clients should use these instead of the legacy `modes`/`models` fields.
|
||||
/// See: docs/architecture/agent_client_protocol/session-config-options.md
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigOption {
|
||||
/// Unique identifier (e.g., "mode", "model")
|
||||
pub id: String,
|
||||
/// Human-readable label
|
||||
pub name: String,
|
||||
/// Optional description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Semantic category for UX grouping (e.g., "mode", "model", "thought_level")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
/// Input type (currently only "select" is defined)
|
||||
#[serde(rename = "type")]
|
||||
pub option_type: ConfigOptionType,
|
||||
/// Currently selected value
|
||||
pub current_value: String,
|
||||
/// Available values for select-type options
|
||||
pub options: Vec<ConfigOptionValue>,
|
||||
}
|
||||
|
||||
/// Type of configuration option input
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfigOptionType {
|
||||
Select,
|
||||
}
|
||||
|
||||
/// A single value choice within a config option
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigOptionValue {
|
||||
/// Value identifier (sent back when setting this option)
|
||||
pub value: String,
|
||||
/// Human-readable display name
|
||||
pub name: String,
|
||||
/// Optional description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionMetadata {
|
||||
pub project_path: String,
|
||||
pub model: Option<String>,
|
||||
/// Total count of user and assistant messages in the session (excludes system messages).
|
||||
/// A value of 0 may indicate either an empty session or that the count has not yet been calculated.
|
||||
/// Counts are populated lazily when messages are loaded for a session.
|
||||
/// See `docs/architecture/session_message_counts.md` for details.
|
||||
pub total_messages: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_message: Option<String>,
|
||||
/// Current mode identifier for future mode tracking
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_mode_id: Option<String>,
|
||||
/// Provider metadata for tracking original IDs and debugging information
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub _meta: Option<Meta>,
|
||||
/// Optional project ID linking this session to a dirigent_projects Project.
|
||||
/// When set, the session belongs to the specified project for organizational purposes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Ownership Model
|
||||
// ============================================================================
|
||||
// These types define how sessions are owned and how tool execution is routed.
|
||||
// See: docs/architecture/session_ownership.md (Phase 7)
|
||||
|
||||
/// The origin of a session - who initiated it
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SessionOrigin {
|
||||
/// Session created by Dirigent UI user
|
||||
Internal,
|
||||
|
||||
/// Session forwarded from external ACP client
|
||||
External {
|
||||
/// The ACP client ID that owns this session
|
||||
client_id: String,
|
||||
/// Cached client capabilities (from initialization)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
client_capabilities: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// Session representing a subagent or internal task (future)
|
||||
Subagent {
|
||||
/// Parent session that spawned this subagent
|
||||
parent_session_id: String,
|
||||
/// Task identifier
|
||||
task_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for SessionOrigin {
|
||||
fn default() -> Self {
|
||||
Self::Internal
|
||||
}
|
||||
}
|
||||
|
||||
/// Who handles tool execution for this session
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolHandler {
|
||||
/// Agent handles its own tools (default)
|
||||
#[default]
|
||||
Agent,
|
||||
|
||||
/// Dirigent intercepts and handles tools via dirigent_tools (future)
|
||||
Dirigent,
|
||||
|
||||
/// Forward tool requests to originating client (External sessions only)
|
||||
ForwardToClient,
|
||||
}
|
||||
|
||||
/// Complete ownership model for a session
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct SessionOwnership {
|
||||
/// Where this session originated
|
||||
#[serde(default)]
|
||||
pub origin: SessionOrigin,
|
||||
|
||||
/// How tool requests are handled
|
||||
#[serde(default)]
|
||||
pub tool_handler: ToolHandler,
|
||||
}
|
||||
|
||||
impl SessionOwnership {
|
||||
/// Internal session with agent handling tools (default UI case)
|
||||
pub fn internal() -> Self {
|
||||
Self {
|
||||
origin: SessionOrigin::Internal,
|
||||
tool_handler: ToolHandler::Agent,
|
||||
}
|
||||
}
|
||||
|
||||
/// External session with tools forwarded to client
|
||||
pub fn external_forwarded(client_id: String, capabilities: Option<serde_json::Value>) -> Self {
|
||||
Self {
|
||||
origin: SessionOrigin::External {
|
||||
client_id,
|
||||
client_capabilities: capabilities,
|
||||
},
|
||||
tool_handler: ToolHandler::ForwardToClient,
|
||||
}
|
||||
}
|
||||
|
||||
/// External session but Dirigent handles tools
|
||||
pub fn external_handled(client_id: String, capabilities: Option<serde_json::Value>) -> Self {
|
||||
Self {
|
||||
origin: SessionOrigin::External {
|
||||
client_id,
|
||||
client_capabilities: capabilities,
|
||||
},
|
||||
tool_handler: ToolHandler::Dirigent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get capabilities to advertise to agent based on ownership
|
||||
pub fn capabilities_for_agent(&self) -> serde_json::Value {
|
||||
match (&self.origin, &self.tool_handler) {
|
||||
// External + ForwardToClient: use client's capabilities
|
||||
(
|
||||
SessionOrigin::External {
|
||||
client_capabilities: Some(caps),
|
||||
..
|
||||
},
|
||||
ToolHandler::ForwardToClient,
|
||||
) => caps.clone(),
|
||||
// Dirigent handles tools: advertise dirigent_tools capabilities
|
||||
(_, ToolHandler::Dirigent) => {
|
||||
serde_json::json!({
|
||||
"fs": { "readTextFile": true, "writeTextFile": true },
|
||||
"terminal": true
|
||||
})
|
||||
}
|
||||
// Agent handles tools or no client caps: empty (agent uses its own)
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the client ID if this should forward requests to a client
|
||||
pub fn forward_to_client(&self) -> Option<&str> {
|
||||
match (&self.origin, &self.tool_handler) {
|
||||
(SessionOrigin::External { client_id, .. }, ToolHandler::ForwardToClient) => {
|
||||
Some(client_id.as_str())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is an external (forwarded) session
|
||||
pub fn is_external(&self) -> bool {
|
||||
matches!(self.origin, SessionOrigin::External { .. })
|
||||
}
|
||||
|
||||
/// Get the originating client ID if external
|
||||
pub fn client_id(&self) -> Option<&str> {
|
||||
match &self.origin {
|
||||
SessionOrigin::External { client_id, .. } => Some(client_id.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::meta::{Meta, ProviderMeta};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ========================================================================
|
||||
// ACP Session Mode/Model Type Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_session_mode_state_serialization_camel_case() {
|
||||
// Verify camelCase serialization matches Claude-ACP format
|
||||
let mode_state = SessionModeState {
|
||||
current_mode_id: "default".to_string(),
|
||||
available_modes: vec![
|
||||
SessionMode {
|
||||
id: "default".to_string(),
|
||||
name: "Always Ask".to_string(),
|
||||
description: Some(
|
||||
"Prompts for permission on first use of each tool".to_string(),
|
||||
),
|
||||
},
|
||||
SessionMode {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan Mode".to_string(),
|
||||
description: Some("Claude can analyze but not modify files".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&mode_state).unwrap();
|
||||
// Verify camelCase field names
|
||||
assert!(json.contains("currentModeId"));
|
||||
assert!(json.contains("availableModes"));
|
||||
// Verify content
|
||||
assert!(json.contains(r#""currentModeId":"default"#));
|
||||
assert!(json.contains(r#""name":"Always Ask"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_mode_state_deserialization_from_claude_format() {
|
||||
// Test deserialization of actual Claude-ACP format
|
||||
let json = r#"{
|
||||
"currentModeId": "default",
|
||||
"availableModes": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Always Ask",
|
||||
"description": "Prompts for permission on first use of each tool"
|
||||
},
|
||||
{
|
||||
"id": "acceptEdits",
|
||||
"name": "Accept Edits",
|
||||
"description": "Automatically accepts file edit permissions for the session"
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"name": "Plan Mode",
|
||||
"description": "Claude can analyze but not modify files or execute commands"
|
||||
},
|
||||
{
|
||||
"id": "bypassPermissions",
|
||||
"name": "Bypass Permissions",
|
||||
"description": "Skips all permission prompts"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let mode_state: SessionModeState = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(mode_state.current_mode_id, "default");
|
||||
assert_eq!(mode_state.available_modes.len(), 4);
|
||||
assert_eq!(mode_state.available_modes[0].id, "default");
|
||||
assert_eq!(mode_state.available_modes[0].name, "Always Ask");
|
||||
assert_eq!(mode_state.available_modes[3].id, "bypassPermissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_mode_state_roundtrip() {
|
||||
let original = SessionModeState {
|
||||
current_mode_id: "plan".to_string(),
|
||||
available_modes: vec![
|
||||
SessionMode {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
description: None,
|
||||
},
|
||||
SessionMode {
|
||||
id: "plan".to_string(),
|
||||
name: "Plan".to_string(),
|
||||
description: Some("Planning mode".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionModeState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_mode_skip_none_description() {
|
||||
// Test that None descriptions are not serialized
|
||||
let mode = SessionMode {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
description: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&mode).unwrap();
|
||||
assert!(!json.contains("description"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_model_state_serialization_camel_case() {
|
||||
// Verify camelCase serialization matches Claude-ACP format
|
||||
let model_state = SessionModelState {
|
||||
available_models: vec![
|
||||
ModelInfo {
|
||||
model_id: "default".to_string(),
|
||||
name: "Default (recommended)".to_string(),
|
||||
description: Some("Opus 4.5 · Most capable for complex work".to_string()),
|
||||
},
|
||||
ModelInfo {
|
||||
model_id: "sonnet".to_string(),
|
||||
name: "Sonnet".to_string(),
|
||||
description: Some("Sonnet 4.5 · Best for everyday tasks".to_string()),
|
||||
},
|
||||
],
|
||||
current_model_id: "default".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&model_state).unwrap();
|
||||
// Verify camelCase field names
|
||||
assert!(json.contains("availableModels"));
|
||||
assert!(json.contains("currentModelId"));
|
||||
assert!(json.contains("modelId"));
|
||||
// Verify content
|
||||
assert!(json.contains(r#""currentModelId":"default"#));
|
||||
assert!(json.contains(r#""name":"Sonnet"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_model_state_deserialization_from_claude_format() {
|
||||
// Test deserialization of actual Claude-ACP format (from zed_claude_code_direct_acp_log.txt)
|
||||
let json = r#"{
|
||||
"availableModels": [
|
||||
{
|
||||
"modelId": "default",
|
||||
"name": "Default (recommended)",
|
||||
"description": "Opus 4.5 · Most capable for complex work"
|
||||
},
|
||||
{
|
||||
"modelId": "sonnet",
|
||||
"name": "Sonnet",
|
||||
"description": "Sonnet 4.5 · Best for everyday tasks"
|
||||
},
|
||||
{
|
||||
"modelId": "haiku",
|
||||
"name": "Haiku",
|
||||
"description": "Haiku 4.5 · Fastest for quick answers"
|
||||
},
|
||||
{
|
||||
"modelId": "opus",
|
||||
"name": "opus",
|
||||
"description": "Custom model"
|
||||
}
|
||||
],
|
||||
"currentModelId": "default"
|
||||
}"#;
|
||||
|
||||
let model_state: SessionModelState = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(model_state.current_model_id, "default");
|
||||
assert_eq!(model_state.available_models.len(), 4);
|
||||
assert_eq!(model_state.available_models[0].model_id, "default");
|
||||
assert_eq!(
|
||||
model_state.available_models[0].name,
|
||||
"Default (recommended)"
|
||||
);
|
||||
assert_eq!(model_state.available_models[2].model_id, "haiku");
|
||||
assert_eq!(model_state.available_models[3].model_id, "opus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_model_state_roundtrip() {
|
||||
let original = SessionModelState {
|
||||
available_models: vec![ModelInfo {
|
||||
model_id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
description: None,
|
||||
}],
|
||||
current_model_id: "default".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionModelState = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_info_skip_none_description() {
|
||||
// Test that None descriptions are not serialized
|
||||
let model = ModelInfo {
|
||||
model_id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
description: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&model).unwrap();
|
||||
assert!(!json.contains("description"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SessionMetadata Tests (existing)
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_backward_compatibility() {
|
||||
// Test that existing SessionMetadata without new fields can be deserialized
|
||||
let json = r#"{
|
||||
"project_path": "/test/path",
|
||||
"model": "gpt-4",
|
||||
"total_messages": 10,
|
||||
"system_message": "System prompt"
|
||||
}"#;
|
||||
|
||||
let metadata: SessionMetadata = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(metadata.project_path, "/test/path");
|
||||
assert_eq!(metadata.model, Some("gpt-4".to_string()));
|
||||
assert_eq!(metadata.total_messages, 10);
|
||||
assert_eq!(metadata.system_message, Some("System prompt".to_string()));
|
||||
assert_eq!(metadata.current_mode_id, None);
|
||||
assert_eq!(metadata._meta, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_skip_serializing_none() {
|
||||
// Test that None values are skipped during serialization
|
||||
let metadata = SessionMetadata {
|
||||
project_path: "/test".to_string(),
|
||||
model: Some("gpt-4".to_string()),
|
||||
total_messages: 0,
|
||||
system_message: None,
|
||||
current_mode_id: None,
|
||||
_meta: None,
|
||||
project_id: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
// Should not contain system_message, current_mode_id, or _meta fields
|
||||
assert!(!json.contains("system_message"));
|
||||
assert!(!json.contains("current_mode_id"));
|
||||
assert!(!json.contains("_meta"));
|
||||
// Should contain the present fields
|
||||
assert!(json.contains("project_path"));
|
||||
assert!(json.contains("model"));
|
||||
assert!(json.contains("total_messages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_with_current_mode_id() {
|
||||
// Test serialization/deserialization with current_mode_id
|
||||
let metadata = SessionMetadata {
|
||||
project_path: "/test".to_string(),
|
||||
model: Some("gpt-4".to_string()),
|
||||
total_messages: 5,
|
||||
system_message: None,
|
||||
current_mode_id: Some("code_mode".to_string()),
|
||||
_meta: None,
|
||||
project_id: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
assert!(json.contains("current_mode_id"));
|
||||
assert!(json.contains("code_mode"));
|
||||
|
||||
let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.current_mode_id, Some("code_mode".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_with_meta() {
|
||||
// Test serialization/deserialization with provider metadata
|
||||
let meta = Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "opencode".to_string(),
|
||||
original_ids: Some(HashMap::from([(
|
||||
"session_id".to_string(),
|
||||
"ses_abc123".to_string(),
|
||||
)])),
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
|
||||
let metadata = SessionMetadata {
|
||||
project_path: "/test".to_string(),
|
||||
model: Some("gpt-4".to_string()),
|
||||
total_messages: 5,
|
||||
system_message: None,
|
||||
current_mode_id: None,
|
||||
_meta: Some(meta),
|
||||
project_id: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
assert!(json.contains("_meta"));
|
||||
assert!(json.contains("opencode"));
|
||||
assert!(json.contains("ses_abc123"));
|
||||
|
||||
let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert!(deserialized._meta.is_some());
|
||||
let deserialized_meta = deserialized._meta.unwrap();
|
||||
assert!(deserialized_meta.provider.is_some());
|
||||
assert_eq!(deserialized_meta.provider.unwrap().name, "opencode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_metadata_with_all_fields() {
|
||||
// Test with all fields populated
|
||||
let meta = Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "anthropic".to_string(),
|
||||
original_ids: Some(HashMap::from([(
|
||||
"conversation_id".to_string(),
|
||||
"conv_xyz".to_string(),
|
||||
)])),
|
||||
raw_excerpt: Some(serde_json::json!({"version": "1.0"})),
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
|
||||
let metadata = SessionMetadata {
|
||||
project_path: "/project".to_string(),
|
||||
model: Some("claude-3".to_string()),
|
||||
total_messages: 42,
|
||||
system_message: Some("Be helpful".to_string()),
|
||||
current_mode_id: Some("architect".to_string()),
|
||||
_meta: Some(meta),
|
||||
project_id: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(metadata, deserialized);
|
||||
assert!(json.contains("system_message"));
|
||||
assert!(json.contains("current_mode_id"));
|
||||
assert!(json.contains("_meta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_roundtrip_with_new_fields() {
|
||||
// Test that a Session with new metadata fields survives roundtrip
|
||||
let now = Utc::now();
|
||||
let session = Session {
|
||||
id: "ses_test123".to_string(),
|
||||
title: "Test Session".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: SessionMetadata {
|
||||
project_path: "/workspace".to_string(),
|
||||
model: Some("gpt-4-turbo".to_string()),
|
||||
total_messages: 7,
|
||||
system_message: Some("You are a coding assistant".to_string()),
|
||||
current_mode_id: Some("debug_mode".to_string()),
|
||||
_meta: Some(Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "test_provider".to_string(),
|
||||
original_ids: None,
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
}),
|
||||
project_id: None,
|
||||
},
|
||||
cwd: None,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
acp_client_id: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&session).unwrap();
|
||||
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(session, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_with_models_and_modes() {
|
||||
// Test Session with models and modes populated
|
||||
let now = Utc::now();
|
||||
let session = Session {
|
||||
id: "ses_test456".to_string(),
|
||||
title: "Test Session with ACP".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: SessionMetadata {
|
||||
project_path: "/workspace".to_string(),
|
||||
model: Some("default".to_string()),
|
||||
total_messages: 5,
|
||||
system_message: None,
|
||||
current_mode_id: Some("default".to_string()),
|
||||
_meta: None,
|
||||
project_id: None,
|
||||
},
|
||||
cwd: None,
|
||||
models: Some(SessionModelState {
|
||||
available_models: vec![
|
||||
ModelInfo {
|
||||
model_id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
description: Some("Default model".to_string()),
|
||||
},
|
||||
ModelInfo {
|
||||
model_id: "sonnet".to_string(),
|
||||
name: "Sonnet".to_string(),
|
||||
description: None,
|
||||
},
|
||||
],
|
||||
current_model_id: "default".to_string(),
|
||||
}),
|
||||
modes: Some(SessionModeState {
|
||||
current_mode_id: "default".to_string(),
|
||||
available_modes: vec![SessionMode {
|
||||
id: "default".to_string(),
|
||||
name: "Always Ask".to_string(),
|
||||
description: None,
|
||||
}],
|
||||
}),
|
||||
config_options: None,
|
||||
acp_client_id: Some("test-client-123".to_string()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&session).unwrap();
|
||||
assert!(json.contains("models"));
|
||||
assert!(json.contains("modes"));
|
||||
assert!(json.contains("acp_client_id"));
|
||||
assert!(json.contains("availableModels"));
|
||||
assert!(json.contains("currentModeId"));
|
||||
|
||||
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(session, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_backward_compatibility_no_models_modes() {
|
||||
// Test that old Session JSON without models/modes can be deserialized
|
||||
let json = r#"{
|
||||
"id": "ses_old",
|
||||
"title": "Old Session",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"metadata": {
|
||||
"project_path": "/test",
|
||||
"model": "gpt-4",
|
||||
"total_messages": 10
|
||||
}
|
||||
}"#;
|
||||
|
||||
let session: Session = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(session.id, "ses_old");
|
||||
assert!(session.models.is_none());
|
||||
assert!(session.modes.is_none());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Session Ownership Model Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_session_origin_default() {
|
||||
// Verify Internal is the default
|
||||
let origin = SessionOrigin::default();
|
||||
assert_eq!(origin, SessionOrigin::Internal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_origin_serialization() {
|
||||
// Test Internal variant
|
||||
let internal = SessionOrigin::Internal;
|
||||
let json = serde_json::to_string(&internal).unwrap();
|
||||
assert!(json.contains(r#""type":"internal"#));
|
||||
|
||||
// Test External variant
|
||||
let external = SessionOrigin::External {
|
||||
client_id: "client-123".to_string(),
|
||||
client_capabilities: Some(serde_json::json!({"tools": ["bash"]})),
|
||||
};
|
||||
let json = serde_json::to_string(&external).unwrap();
|
||||
assert!(json.contains(r#""type":"external"#));
|
||||
assert!(json.contains(r#""client_id":"client-123"#));
|
||||
assert!(json.contains("tools"));
|
||||
|
||||
// Test Subagent variant
|
||||
let subagent = SessionOrigin::Subagent {
|
||||
parent_session_id: "parent-456".to_string(),
|
||||
task_id: "task-789".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&subagent).unwrap();
|
||||
assert!(json.contains(r#""type":"subagent"#));
|
||||
assert!(json.contains(r#""parent_session_id":"parent-456"#));
|
||||
assert!(json.contains(r#""task_id":"task-789"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_handler_default() {
|
||||
// Verify Agent is the default
|
||||
let handler = ToolHandler::default();
|
||||
assert_eq!(handler, ToolHandler::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_handler_serialization() {
|
||||
let agent = ToolHandler::Agent;
|
||||
let json = serde_json::to_string(&agent).unwrap();
|
||||
assert_eq!(json, r#""agent""#);
|
||||
|
||||
let dirigent = ToolHandler::Dirigent;
|
||||
let json = serde_json::to_string(&dirigent).unwrap();
|
||||
assert_eq!(json, r#""dirigent""#);
|
||||
|
||||
let forward = ToolHandler::ForwardToClient;
|
||||
let json = serde_json::to_string(&forward).unwrap();
|
||||
assert_eq!(json, r#""forward_to_client""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_ownership_internal() {
|
||||
let ownership = SessionOwnership::internal();
|
||||
assert_eq!(ownership.origin, SessionOrigin::Internal);
|
||||
assert_eq!(ownership.tool_handler, ToolHandler::Agent);
|
||||
assert!(!ownership.is_external());
|
||||
assert_eq!(ownership.client_id(), None);
|
||||
assert_eq!(ownership.forward_to_client(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_ownership_external_forwarded() {
|
||||
let caps = serde_json::json!({"tools": ["bash", "edit"]});
|
||||
let ownership =
|
||||
SessionOwnership::external_forwarded("client-123".to_string(), Some(caps.clone()));
|
||||
|
||||
match &ownership.origin {
|
||||
SessionOrigin::External {
|
||||
client_id,
|
||||
client_capabilities,
|
||||
} => {
|
||||
assert_eq!(client_id, "client-123");
|
||||
assert_eq!(client_capabilities.as_ref().unwrap(), &caps);
|
||||
}
|
||||
_ => panic!("Expected External origin"),
|
||||
}
|
||||
|
||||
assert_eq!(ownership.tool_handler, ToolHandler::ForwardToClient);
|
||||
assert!(ownership.is_external());
|
||||
assert_eq!(ownership.client_id(), Some("client-123"));
|
||||
assert_eq!(ownership.forward_to_client(), Some("client-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_ownership_external_handled() {
|
||||
let ownership = SessionOwnership::external_handled("client-456".to_string(), None);
|
||||
|
||||
match &ownership.origin {
|
||||
SessionOrigin::External {
|
||||
client_id,
|
||||
client_capabilities,
|
||||
} => {
|
||||
assert_eq!(client_id, "client-456");
|
||||
assert!(client_capabilities.is_none());
|
||||
}
|
||||
_ => panic!("Expected External origin"),
|
||||
}
|
||||
|
||||
assert_eq!(ownership.tool_handler, ToolHandler::Dirigent);
|
||||
assert!(ownership.is_external());
|
||||
assert_eq!(ownership.client_id(), Some("client-456"));
|
||||
assert_eq!(ownership.forward_to_client(), None); // Dirigent handles, not forwarded
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_for_agent_external_forwarded() {
|
||||
let client_caps = serde_json::json!({"fs": true, "terminal": true});
|
||||
let ownership = SessionOwnership::external_forwarded(
|
||||
"client-123".to_string(),
|
||||
Some(client_caps.clone()),
|
||||
);
|
||||
|
||||
let caps = ownership.capabilities_for_agent();
|
||||
assert_eq!(caps, client_caps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_for_agent_dirigent() {
|
||||
let ownership = SessionOwnership {
|
||||
origin: SessionOrigin::Internal,
|
||||
tool_handler: ToolHandler::Dirigent,
|
||||
};
|
||||
|
||||
let caps = ownership.capabilities_for_agent();
|
||||
assert!(caps.is_object());
|
||||
assert!(caps.get("fs").is_some());
|
||||
assert!(caps.get("terminal").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_for_agent_agent_handled() {
|
||||
let ownership = SessionOwnership::internal();
|
||||
let caps = ownership.capabilities_for_agent();
|
||||
assert_eq!(caps, serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_ownership_serialization_roundtrip() {
|
||||
let original = SessionOwnership::external_forwarded(
|
||||
"test-client".to_string(),
|
||||
Some(serde_json::json!({"test": true})),
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionOwnership = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.tool_handler, original.tool_handler);
|
||||
assert_eq!(deserialized.client_id(), Some("test-client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_ownership_default() {
|
||||
let ownership = SessionOwnership::default();
|
||||
assert_eq!(ownership.origin, SessionOrigin::Internal);
|
||||
assert_eq!(ownership.tool_handler, ToolHandler::Agent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Session sharing abstraction
|
||||
//!
|
||||
//! The `SessionShare` trait abstracts bidirectional bridges between Dirigent
|
||||
//! sessions and external communication systems (Matrix, Slack, etc.).
|
||||
//!
|
||||
//! A share attaches to a (connector_id, session_id) pair without taking
|
||||
//! ownership of the session. Multiple shares can coexist on the same session.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Unique identifier for a share instance.
|
||||
pub type ShareId = String;
|
||||
|
||||
/// Summary info about an active share.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShareSummary {
|
||||
/// Share identifier (e.g., "matrix:connector-1:session-abc")
|
||||
pub id: ShareId,
|
||||
/// Connector this share is attached to
|
||||
pub connector_id: String,
|
||||
/// Session this share is attached to
|
||||
pub session_id: String,
|
||||
/// Backend type (e.g., "matrix", "slack")
|
||||
pub backend: String,
|
||||
/// Backend-specific destination (e.g., Matrix room ID)
|
||||
pub destination: String,
|
||||
/// Whether the share is currently active
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Trait for session share backends.
|
||||
///
|
||||
/// Implementors provide bidirectional bridging between a Dirigent session
|
||||
/// and an external system. The trait is deliberately minimal — the concrete
|
||||
/// implementation handles all backend-specific details.
|
||||
///
|
||||
/// # Design Notes
|
||||
///
|
||||
/// - Shares do NOT modify the Connector trait or require special connector support
|
||||
/// - Shares use existing channels: `connector.subscribe()` for events,
|
||||
/// `connector.command_tx()` for sending messages
|
||||
/// - Multiple shares can be active on the same session simultaneously
|
||||
#[async_trait]
|
||||
pub trait SessionShare: Send + Sync {
|
||||
/// Get summary information about this share.
|
||||
fn summary(&self) -> ShareSummary;
|
||||
|
||||
/// Check if the share is actively forwarding.
|
||||
fn is_active(&self) -> bool;
|
||||
|
||||
/// Gracefully shut down this share.
|
||||
async fn shutdown(&self);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Bus event envelope: wraps `Event` with routing context for subscribers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::Event;
|
||||
|
||||
/// Full bus event envelope: the `Event` plus routing context derived at emit time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BusEvent {
|
||||
pub routing: EventRouting,
|
||||
pub origin: EventOrigin,
|
||||
pub event: Arc<Event>,
|
||||
}
|
||||
|
||||
/// Routing metadata attached to every `BusEvent`.
|
||||
///
|
||||
/// `scroll_id` is intentionally left `None` at construction time and filled in
|
||||
/// later by the bus cache once the archivist has registered the session.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EventRouting {
|
||||
pub connector_uid: Option<Uuid>,
|
||||
pub scroll_id: Option<Uuid>,
|
||||
pub connector_id: Option<String>,
|
||||
pub native_session_id: Option<String>,
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// High-level classification of a `BusEvent`, used for subscriber filtering.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventKind {
|
||||
#[default]
|
||||
SessionLifecycle,
|
||||
Message,
|
||||
Update,
|
||||
System,
|
||||
}
|
||||
|
||||
/// Records the subsystem that originally produced the event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EventOrigin {
|
||||
Connector {
|
||||
connector_uid: Option<Uuid>,
|
||||
connector_id: String,
|
||||
},
|
||||
Archivist,
|
||||
Runtime,
|
||||
Replay {
|
||||
replay_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
// ─── BusEvent constructors ───────────────────────────────────────────────────
|
||||
|
||||
impl BusEvent {
|
||||
/// Wrap a connector-sourced `Event` in a `BusEvent`.
|
||||
///
|
||||
/// Routing metadata is derived from the event fields; `scroll_id` is left
|
||||
/// `None` and must be patched by the bus cache after archivist registration.
|
||||
pub fn from_connector_event(
|
||||
event: Event,
|
||||
connector_uid: Option<Uuid>,
|
||||
connector_id: String,
|
||||
) -> Self {
|
||||
let routing = EventRouting::derive(&event, connector_uid, &connector_id);
|
||||
Self {
|
||||
routing,
|
||||
origin: EventOrigin::Connector {
|
||||
connector_uid,
|
||||
connector_id,
|
||||
},
|
||||
event: Arc::new(event),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap an archivist-sourced `Event` (e.g. `SessionRegistered`) in a
|
||||
/// `BusEvent`. The archivist knows the canonical `(connector_id,
|
||||
/// native_session_id, scroll_id)` triple; we pass it directly so the
|
||||
/// bus does not have to consult its scroll-id cache to route the
|
||||
/// event. Origin is set to `EventOrigin::Archivist`.
|
||||
pub fn from_archivist_event(
|
||||
event: Event,
|
||||
connector_id: &str,
|
||||
native_session_id: &str,
|
||||
scroll_id: Option<Uuid>,
|
||||
) -> Self {
|
||||
let (kind, _) = classify(&event);
|
||||
let routing = EventRouting {
|
||||
connector_uid: None,
|
||||
scroll_id,
|
||||
connector_id: Some(connector_id.to_string()),
|
||||
native_session_id: Some(native_session_id.to_string()),
|
||||
kind,
|
||||
};
|
||||
Self {
|
||||
routing,
|
||||
origin: EventOrigin::Archivist,
|
||||
event: Arc::new(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EventRouting::derive ────────────────────────────────────────────────────
|
||||
|
||||
impl EventRouting {
|
||||
/// Derive routing from an `Event` plus the emitting connector's identity.
|
||||
///
|
||||
/// `scroll_id` is always `None` here; the bus cache fills it in later.
|
||||
pub fn derive(event: &Event, connector_uid: Option<Uuid>, connector_id: &str) -> Self {
|
||||
let (kind, native_session_id) = classify(event);
|
||||
Self {
|
||||
connector_uid,
|
||||
scroll_id: None,
|
||||
connector_id: Some(connector_id.to_string()),
|
||||
native_session_id,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the `(EventKind, Option<native_session_id>)` for a given `Event`.
|
||||
///
|
||||
/// Every current variant is matched explicitly; the `_` arm is a safety net for
|
||||
/// future additions and classifies them as `System` with no session context.
|
||||
fn classify(event: &Event) -> (EventKind, Option<String>) {
|
||||
use EventKind::*;
|
||||
|
||||
match event {
|
||||
// ── SessionLifecycle ─────────────────────────────────────────────────
|
||||
Event::SessionsListed { .. } => (SessionLifecycle, None),
|
||||
|
||||
Event::SessionCreated { session, .. } => {
|
||||
(SessionLifecycle, Some(session.id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionUpdated { session, .. } => {
|
||||
(SessionLifecycle, Some(session.id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionMetadataUpdated { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionDeleted { session_id } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionClosed { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionSystemMessageSet { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionIdle { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionMetadataReceived { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::SessionTransferred { from_session, .. } => {
|
||||
(SessionLifecycle, Some(from_session.clone()))
|
||||
}
|
||||
|
||||
Event::SessionRegistered { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::ForwardingPanic { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
Event::Connected => (SessionLifecycle, None),
|
||||
Event::Disconnected => (SessionLifecycle, None),
|
||||
|
||||
Event::ConnectorCreated { .. } => (SessionLifecycle, None),
|
||||
Event::ConnectorRemoved { .. } => (SessionLifecycle, None),
|
||||
Event::ConnectorStateChanged { .. } => (SessionLifecycle, None),
|
||||
|
||||
Event::AcpClientConnected { .. } => (SessionLifecycle, None),
|
||||
Event::AcpClientDisconnected { .. } => (SessionLifecycle, None),
|
||||
|
||||
Event::AcpClientSessionOpened {
|
||||
client_session_id, ..
|
||||
} => (SessionLifecycle, Some(client_session_id.clone())),
|
||||
|
||||
Event::AcpClientSessionRouted { from_session_id, .. } => {
|
||||
(SessionLifecycle, Some(from_session_id.clone()))
|
||||
}
|
||||
|
||||
Event::AgentRequest { session_id, .. } => {
|
||||
(SessionLifecycle, Some(session_id.clone()))
|
||||
}
|
||||
|
||||
// ── Message ──────────────────────────────────────────────────────────
|
||||
Event::MessagesListed { .. } => (Message, None),
|
||||
|
||||
Event::MessageStarted { message, .. } => {
|
||||
(Message, Some(message.session_id.clone()))
|
||||
}
|
||||
|
||||
Event::MessageCompleted { message, .. } => {
|
||||
(Message, Some(message.session_id.clone()))
|
||||
}
|
||||
|
||||
Event::MessageFailed { .. } => (Message, None),
|
||||
|
||||
Event::TurnComplete { session_id, .. } => (Message, Some(session_id.clone())),
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────
|
||||
Event::SessionUpdate { session_id, .. } => (Update, Some(session_id.clone())),
|
||||
|
||||
// ── System ───────────────────────────────────────────────────────────
|
||||
Event::Error { .. } => (System, None),
|
||||
Event::SessionError { session_id, .. } => (System, Some(session_id.clone())),
|
||||
Event::InspectorSnapshot { .. } => (System, None),
|
||||
Event::InspectorNodeRegistered { .. } => (System, None),
|
||||
Event::InspectorNodeRemoved { .. } => (System, None),
|
||||
Event::InspectorStateChanged { .. } => (System, None),
|
||||
Event::InspectorPropertiesUpdated { .. } => (System, None),
|
||||
Event::SystemTaskStatusChanged { .. } => (System, None),
|
||||
|
||||
// Safety net: future variants not yet listed above.
|
||||
// #[allow] is intentional — this arm exists to catch additions to Event.
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => (System, None),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Event, Session, SessionMetadata};
|
||||
use chrono::Utc;
|
||||
|
||||
fn minimal_session(id: &str) -> Session {
|
||||
let now = Utc::now();
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
title: "test".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metadata: SessionMetadata {
|
||||
project_path: "/tmp".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,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_extracts_session_id_on_session_created() {
|
||||
let event = Event::SessionCreated {
|
||||
connector_id: "conn-1".to_string(),
|
||||
session: minimal_session("ses-abc"),
|
||||
};
|
||||
let routing = EventRouting::derive(&event, None, "conn-1");
|
||||
assert_eq!(routing.native_session_id.as_deref(), Some("ses-abc"));
|
||||
assert_eq!(routing.kind, EventKind::SessionLifecycle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_sets_kind_update_on_session_update() {
|
||||
use crate::SessionUpdate as SU;
|
||||
let event = Event::SessionUpdate {
|
||||
connector_id: "conn-1".to_string(),
|
||||
session_id: "ses-xyz".to_string(),
|
||||
update: SU::Unknown {
|
||||
data: serde_json::json!({"type": "unknown_future"}),
|
||||
},
|
||||
};
|
||||
let routing = EventRouting::derive(&event, None, "conn-1");
|
||||
assert_eq!(routing.kind, EventKind::Update);
|
||||
assert_eq!(routing.native_session_id.as_deref(), Some("ses-xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_sets_kind_message_for_message_started() {
|
||||
use crate::conversation::{Message, MessageRole, MessageStatus};
|
||||
let now = Utc::now();
|
||||
let msg = Message {
|
||||
id: "msg-1".to_string(),
|
||||
session_id: "ses-msg".to_string(),
|
||||
role: MessageRole::User,
|
||||
created_at: now,
|
||||
content: vec![],
|
||||
status: MessageStatus::Pending,
|
||||
metadata: None,
|
||||
};
|
||||
let event = Event::MessageStarted {
|
||||
connector_id: "conn-1".to_string(),
|
||||
message: msg,
|
||||
};
|
||||
let routing = EventRouting::derive(&event, None, "conn-1");
|
||||
assert_eq!(routing.kind, EventKind::Message);
|
||||
assert_eq!(routing.native_session_id.as_deref(), Some("ses-msg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_sets_kind_system_for_error() {
|
||||
let event = Event::Error {
|
||||
message: "something went wrong".to_string(),
|
||||
};
|
||||
let routing = EventRouting::derive(&event, None, "conn-1");
|
||||
assert_eq!(routing.kind, EventKind::System);
|
||||
assert!(routing.native_session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_origin_replay_roundtrips_replay_id() {
|
||||
let id = Uuid::new_v4();
|
||||
let origin = EventOrigin::Replay { replay_id: id };
|
||||
match &origin {
|
||||
EventOrigin::Replay { replay_id } => assert_eq!(*replay_id, id),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_connector_event_produces_connector_origin() {
|
||||
let event = Event::Connected;
|
||||
let uid = Uuid::nil();
|
||||
let bus = BusEvent::from_connector_event(event, Some(uid), "conn-test".to_string());
|
||||
match &bus.origin {
|
||||
EventOrigin::Connector {
|
||||
connector_uid,
|
||||
connector_id,
|
||||
} => {
|
||||
assert_eq!(*connector_uid, Some(uid));
|
||||
assert_eq!(connector_id, "conn-test");
|
||||
}
|
||||
_ => panic!("expected Connector origin"),
|
||||
}
|
||||
assert_eq!(bus.routing.connector_id.as_deref(), Some("conn-test"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Filters applied on the subscriber side of the SharingBus.
|
||||
|
||||
use std::ops::{BitOr, BitOrAssign};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::bus_event::{BusEvent, EventKind};
|
||||
|
||||
/// A subscriber-side predicate that selects which `BusEvent`s to forward.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EventFilter {
|
||||
/// Accept every event unconditionally.
|
||||
All,
|
||||
/// Accept only events whose `routing.scroll_id` matches the given UUID.
|
||||
ScrollId(Uuid),
|
||||
/// Accept only events whose `routing.connector_uid` matches the given UUID.
|
||||
ConnectorUid(Uuid),
|
||||
/// Accept only events whose `routing.kind` is set in the mask.
|
||||
Kinds(EventKindMask),
|
||||
/// Accept events that satisfy at least one of the inner filters.
|
||||
AnyOf(Vec<EventFilter>),
|
||||
/// Accept events that satisfy all of the inner filters.
|
||||
AllOf(Vec<EventFilter>),
|
||||
}
|
||||
|
||||
/// Bit-mask over `EventKind` variants for efficient kind-based filtering.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct EventKindMask(pub u8);
|
||||
|
||||
impl EventKindMask {
|
||||
pub const SESSION_LIFECYCLE: Self = Self(1 << 0);
|
||||
pub const MESSAGE: Self = Self(1 << 1);
|
||||
pub const UPDATE: Self = Self(1 << 2);
|
||||
pub const SYSTEM: Self = Self(1 << 3);
|
||||
pub const ALL: Self = Self(0b1111);
|
||||
|
||||
/// Returns `true` if `kind` is set in this mask.
|
||||
pub fn contains(self, kind: EventKind) -> bool {
|
||||
let bit = match kind {
|
||||
EventKind::SessionLifecycle => Self::SESSION_LIFECYCLE,
|
||||
EventKind::Message => Self::MESSAGE,
|
||||
EventKind::Update => Self::UPDATE,
|
||||
EventKind::System => Self::SYSTEM,
|
||||
};
|
||||
(self.0 & bit.0) != 0
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOr for EventKindMask {
|
||||
type Output = Self;
|
||||
|
||||
fn bitor(self, rhs: Self) -> Self::Output {
|
||||
Self(self.0 | rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOrAssign for EventKindMask {
|
||||
fn bitor_assign(&mut self, rhs: Self) {
|
||||
self.0 |= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl EventFilter {
|
||||
/// Returns `true` if this filter accepts the given `BusEvent`.
|
||||
pub fn matches(&self, event: &BusEvent) -> bool {
|
||||
match self {
|
||||
EventFilter::All => true,
|
||||
EventFilter::ScrollId(s) => event.routing.scroll_id == Some(*s),
|
||||
EventFilter::ConnectorUid(u) => event.routing.connector_uid == Some(*u),
|
||||
EventFilter::Kinds(m) => m.contains(event.routing.kind),
|
||||
EventFilter::AnyOf(filters) => filters.iter().any(|f| f.matches(event)),
|
||||
EventFilter::AllOf(filters) => filters.iter().all(|f| f.matches(event)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
streaming::bus_event::{EventOrigin, EventRouting},
|
||||
Event,
|
||||
};
|
||||
|
||||
/// Build a minimal `BusEvent` for testing.
|
||||
fn make_event(
|
||||
scroll_id: Option<Uuid>,
|
||||
connector_uid: Option<Uuid>,
|
||||
kind: EventKind,
|
||||
) -> BusEvent {
|
||||
BusEvent {
|
||||
routing: EventRouting {
|
||||
scroll_id,
|
||||
connector_uid,
|
||||
kind,
|
||||
..Default::default()
|
||||
},
|
||||
origin: EventOrigin::Runtime,
|
||||
event: Arc::new(Event::Connected),
|
||||
}
|
||||
}
|
||||
|
||||
// 1. EventFilter::All matches any BusEvent.
|
||||
#[test]
|
||||
fn all_matches_any_event() {
|
||||
let ev = make_event(None, None, EventKind::System);
|
||||
assert!(EventFilter::All.matches(&ev));
|
||||
|
||||
let ev2 = make_event(Some(Uuid::new_v4()), Some(Uuid::new_v4()), EventKind::Message);
|
||||
assert!(EventFilter::All.matches(&ev2));
|
||||
}
|
||||
|
||||
// 2. EventFilter::ScrollId matches Some(x), rejects Some(y), rejects None.
|
||||
#[test]
|
||||
fn scroll_id_matches_correct_uuid_only() {
|
||||
let x = Uuid::new_v4();
|
||||
let y = Uuid::new_v4();
|
||||
|
||||
let filter = EventFilter::ScrollId(x);
|
||||
|
||||
let ev_match = make_event(Some(x), None, EventKind::SessionLifecycle);
|
||||
assert!(filter.matches(&ev_match), "should match Some(x)");
|
||||
|
||||
let ev_other = make_event(Some(y), None, EventKind::SessionLifecycle);
|
||||
assert!(!filter.matches(&ev_other), "should reject Some(y)");
|
||||
|
||||
let ev_none = make_event(None, None, EventKind::SessionLifecycle);
|
||||
assert!(!filter.matches(&ev_none), "should reject None");
|
||||
}
|
||||
|
||||
// 3. EventFilter::ConnectorUid matches only when routing.connector_uid == Some(u).
|
||||
#[test]
|
||||
fn connector_uid_matches_correct_uuid_only() {
|
||||
let u = Uuid::new_v4();
|
||||
let other = Uuid::new_v4();
|
||||
|
||||
let filter = EventFilter::ConnectorUid(u);
|
||||
|
||||
let ev_match = make_event(None, Some(u), EventKind::Update);
|
||||
assert!(filter.matches(&ev_match));
|
||||
|
||||
let ev_other = make_event(None, Some(other), EventKind::Update);
|
||||
assert!(!filter.matches(&ev_other));
|
||||
|
||||
let ev_none = make_event(None, None, EventKind::Update);
|
||||
assert!(!filter.matches(&ev_none));
|
||||
}
|
||||
|
||||
// 4. EventFilter::Kinds(MESSAGE) matches Message, rejects Update.
|
||||
#[test]
|
||||
fn kinds_mask_message_matches_message_only() {
|
||||
let filter = EventFilter::Kinds(EventKindMask::MESSAGE);
|
||||
|
||||
let ev_msg = make_event(None, None, EventKind::Message);
|
||||
assert!(filter.matches(&ev_msg));
|
||||
|
||||
let ev_upd = make_event(None, None, EventKind::Update);
|
||||
assert!(!filter.matches(&ev_upd));
|
||||
}
|
||||
|
||||
// 5. AnyOf([ScrollId(X), ConnectorUid(Y)]) matches when either matches, rejects otherwise.
|
||||
#[test]
|
||||
fn any_of_matches_when_at_least_one_sub_filter_matches() {
|
||||
let x = Uuid::new_v4();
|
||||
let y = Uuid::new_v4();
|
||||
let z = Uuid::new_v4();
|
||||
|
||||
let filter = EventFilter::AnyOf(vec![
|
||||
EventFilter::ScrollId(x),
|
||||
EventFilter::ConnectorUid(y),
|
||||
]);
|
||||
|
||||
// scroll_id matches
|
||||
let ev_scroll = make_event(Some(x), None, EventKind::Message);
|
||||
assert!(filter.matches(&ev_scroll));
|
||||
|
||||
// connector_uid matches
|
||||
let ev_conn = make_event(None, Some(y), EventKind::Message);
|
||||
assert!(filter.matches(&ev_conn));
|
||||
|
||||
// both match
|
||||
let ev_both = make_event(Some(x), Some(y), EventKind::Message);
|
||||
assert!(filter.matches(&ev_both));
|
||||
|
||||
// neither matches
|
||||
let ev_neither = make_event(Some(z), Some(z), EventKind::System);
|
||||
assert!(!filter.matches(&ev_neither));
|
||||
}
|
||||
|
||||
// 6. AllOf([ScrollId(X), Kinds(MESSAGE)]) matches only when both hold.
|
||||
#[test]
|
||||
fn all_of_matches_only_when_all_sub_filters_match() {
|
||||
let x = Uuid::new_v4();
|
||||
|
||||
let filter = EventFilter::AllOf(vec![
|
||||
EventFilter::ScrollId(x),
|
||||
EventFilter::Kinds(EventKindMask::MESSAGE),
|
||||
]);
|
||||
|
||||
// both conditions satisfied
|
||||
let ev_both = make_event(Some(x), None, EventKind::Message);
|
||||
assert!(filter.matches(&ev_both));
|
||||
|
||||
// scroll_id matches but wrong kind
|
||||
let ev_wrong_kind = make_event(Some(x), None, EventKind::Update);
|
||||
assert!(!filter.matches(&ev_wrong_kind));
|
||||
|
||||
// right kind but wrong scroll_id
|
||||
let ev_wrong_scroll = make_event(Some(Uuid::new_v4()), None, EventKind::Message);
|
||||
assert!(!filter.matches(&ev_wrong_scroll));
|
||||
|
||||
// neither matches
|
||||
let ev_neither = make_event(None, None, EventKind::System);
|
||||
assert!(!filter.matches(&ev_neither));
|
||||
}
|
||||
|
||||
// 7. BitOr combining masks: (MESSAGE | UPDATE).contains(kind) is true for both.
|
||||
#[test]
|
||||
fn bitor_combines_masks_correctly() {
|
||||
let combined = EventKindMask::MESSAGE | EventKindMask::UPDATE;
|
||||
|
||||
assert!(combined.contains(EventKind::Message));
|
||||
assert!(combined.contains(EventKind::Update));
|
||||
assert!(!combined.contains(EventKind::SessionLifecycle));
|
||||
assert!(!combined.contains(EventKind::System));
|
||||
}
|
||||
|
||||
// Bonus: verify BitOrAssign works the same way.
|
||||
#[test]
|
||||
fn bitor_assign_accumulates_bits() {
|
||||
let mut mask = EventKindMask::MESSAGE;
|
||||
mask |= EventKindMask::SYSTEM;
|
||||
|
||||
assert!(mask.contains(EventKind::Message));
|
||||
assert!(mask.contains(EventKind::System));
|
||||
assert!(!mask.contains(EventKind::Update));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Streaming primitives shared across the runtime, archivist, and sink crates.
|
||||
//!
|
||||
//! `BusEvent` wraps the existing `Event` with routing context; `SessionStream`
|
||||
//! is the trait every uni-directional sink implements. The existing
|
||||
//! `SessionShare` trait (bi-directional, Matrix) lives in `crate::sharing`
|
||||
//! and is not superseded.
|
||||
|
||||
pub mod bus_event;
|
||||
pub mod filter;
|
||||
pub mod receiver;
|
||||
pub mod stream;
|
||||
|
||||
pub use bus_event::{BusEvent, EventKind, EventOrigin, EventRouting};
|
||||
pub use filter::{EventFilter, EventKindMask};
|
||||
pub use receiver::BusReceiver;
|
||||
pub use stream::{
|
||||
SessionStream, StreamError, StreamKind, StreamOutcome, StreamScope, StreamSummary,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
//! `BusReceiver`: subscriber handle returned by a SharingBus-like fan-out.
|
||||
//!
|
||||
//! This type lives in `dirigent_protocol` (rather than next to its only
|
||||
//! producer in `dirigent_core::sharing::bus`) so that downstream consumers
|
||||
//! such as `dirigent_archivist` can accept a `BusReceiver` without taking
|
||||
//! on a dependency on `dirigent_core` — which would be a dependency cycle.
|
||||
//!
|
||||
//! It is intentionally a dumb data container: just `id`, `rx`, and the
|
||||
//! `lagged` counter that the producer task increments when it has to drop
|
||||
//! events for a slow subscriber. No logic lives here.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::streaming::BusEvent;
|
||||
|
||||
/// Receiver handle returned to subscribers by a SharingBus-style fan-out.
|
||||
///
|
||||
/// `lagged` counts how many events were dropped because the underlying
|
||||
/// mpsc queue was full when the worker tried to deliver.
|
||||
pub struct BusReceiver {
|
||||
pub id: u64,
|
||||
pub rx: mpsc::Receiver<BusEvent>,
|
||||
pub lagged: Arc<AtomicU64>,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! SessionStream: uni-directional sink trait. Archive backends use
|
||||
//! ArchiveBackend; live-write sinks like Langfuse use SessionStream.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::bus_event::BusEvent;
|
||||
|
||||
#[async_trait]
|
||||
pub trait SessionStream: Send + Sync {
|
||||
fn summary(&self) -> StreamSummary;
|
||||
fn scope(&self) -> StreamScope;
|
||||
async fn on_event(&self, event: &BusEvent) -> StreamOutcome;
|
||||
async fn shutdown(&self);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamSummary {
|
||||
pub name: String,
|
||||
pub kind: StreamKind,
|
||||
pub target: String, // human-readable ("langfuse: https://…", "matrix: #room:server")
|
||||
pub active_since: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StreamKind {
|
||||
Matrix,
|
||||
Langfuse,
|
||||
Slack,
|
||||
Webhook,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum StreamScope {
|
||||
Session { scroll_id: Uuid },
|
||||
Connector { connector_uid: Uuid },
|
||||
ArchiveWide { acknowledged: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StreamOutcome {
|
||||
Ok,
|
||||
Skipped,
|
||||
Failed(StreamError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StreamError {
|
||||
#[error("transport: {0}")] Transport(String),
|
||||
#[error("serialisation: {0}")] Serialisation(String),
|
||||
#[error("rejected: {0}")] Rejected(String),
|
||||
#[error("shutdown")] Shutdown,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// MCP-style content block for displayable content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ResourceLink {
|
||||
uri: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
// Future: Resource, Image, Audio (marked as out-of-scope for phase 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_text_serialization() {
|
||||
let block = ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains(r#""type":"text"#));
|
||||
assert!(json.contains(r#""text":"Hello"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resource_link_serialization() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file".to_string(),
|
||||
name: Some("file.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains(r#""type":"resource_link"#));
|
||||
assert!(json.contains(r#""uri":"file:///path/to/file"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let block = ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct Meta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<ProviderMeta>,
|
||||
|
||||
/// Arbitrary extra fields
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProviderMeta {
|
||||
/// Provider name (e.g., "opencode", "anthropic")
|
||||
pub name: String,
|
||||
|
||||
/// Original provider-specific IDs
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub original_ids: Option<HashMap<String, String>>,
|
||||
|
||||
/// Minimal raw payload excerpts for debugging (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_excerpt: Option<Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_meta_default() {
|
||||
let meta = Meta::default();
|
||||
assert_eq!(meta.provider, None);
|
||||
assert!(meta.extra.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_with_provider() {
|
||||
let meta = Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "opencode".to_string(),
|
||||
original_ids: Some(HashMap::from([
|
||||
("session_id".to_string(), "abc123".to_string()),
|
||||
])),
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
assert!(json.contains(r#""name":"opencode"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_serializing_if_none() {
|
||||
let meta = Meta::default();
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
// Should be empty object since provider is None
|
||||
assert_eq!(json, "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip() {
|
||||
let meta = Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "test".to_string(),
|
||||
original_ids: None,
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
let deserialized: Meta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(meta, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_preserves_claude_code_tool_response() {
|
||||
use serde_json::json;
|
||||
|
||||
// Test that complex nested _meta data is preserved (T013 requirement)
|
||||
let meta_json = json!({
|
||||
"claudeCode": {
|
||||
"toolResponse": {
|
||||
"mode": "content",
|
||||
"numFiles": 0,
|
||||
"filenames": [],
|
||||
"content": "some grep output here",
|
||||
"numLines": 58,
|
||||
"appliedLimit": 100
|
||||
},
|
||||
"toolName": "Grep"
|
||||
}
|
||||
});
|
||||
|
||||
// Deserialize into Meta
|
||||
let meta: Meta = serde_json::from_value(meta_json.clone()).unwrap();
|
||||
|
||||
// Verify claudeCode is in extra
|
||||
assert!(meta.extra.contains_key("claudeCode"));
|
||||
|
||||
// Serialize back to JSON
|
||||
let serialized = serde_json::to_value(&meta).unwrap();
|
||||
|
||||
// Verify all fields are preserved
|
||||
assert_eq!(serialized["claudeCode"]["toolName"], "Grep");
|
||||
assert!(serialized["claudeCode"]["toolResponse"].is_object());
|
||||
assert_eq!(serialized["claudeCode"]["toolResponse"]["mode"], "content");
|
||||
assert_eq!(serialized["claudeCode"]["toolResponse"]["numFiles"], 0);
|
||||
assert_eq!(serialized["claudeCode"]["toolResponse"]["numLines"], 58);
|
||||
assert_eq!(serialized["claudeCode"]["toolResponse"]["appliedLimit"], 100);
|
||||
assert_eq!(
|
||||
serialized["claudeCode"]["toolResponse"]["content"],
|
||||
"some grep output here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_round_trip_preservation() {
|
||||
use serde_json::json;
|
||||
|
||||
// Test incoming → serialize → deserialize → serialize preserves all fields (T013)
|
||||
let original_meta_json = json!({
|
||||
"claudeCode": {
|
||||
"toolResponse": {
|
||||
"mode": "content",
|
||||
"numFiles": 3,
|
||||
"filenames": ["file1.rs", "file2.rs", "file3.rs"],
|
||||
"content": "match results",
|
||||
"numLines": 42,
|
||||
"appliedLimit": 100,
|
||||
"customField": "should be preserved"
|
||||
},
|
||||
"toolName": "Grep",
|
||||
"additionalField": "also preserved"
|
||||
},
|
||||
"provider": {
|
||||
"name": "anthropic",
|
||||
"original_ids": {
|
||||
"session_id": "sess_123"
|
||||
}
|
||||
},
|
||||
"customTopLevel": "preserved too"
|
||||
});
|
||||
|
||||
// First round trip
|
||||
let meta1: Meta = serde_json::from_value(original_meta_json.clone()).unwrap();
|
||||
let json1 = serde_json::to_value(&meta1).unwrap();
|
||||
|
||||
// Second round trip
|
||||
let meta2: Meta = serde_json::from_value(json1.clone()).unwrap();
|
||||
let json2 = serde_json::to_value(&meta2).unwrap();
|
||||
|
||||
// Verify both serializations are identical (stable)
|
||||
assert_eq!(json1, json2);
|
||||
|
||||
// Verify all nested fields preserved
|
||||
assert_eq!(json2["claudeCode"]["toolResponse"]["customField"], "should be preserved");
|
||||
assert_eq!(json2["claudeCode"]["additionalField"], "also preserved");
|
||||
assert_eq!(json2["customTopLevel"], "preserved too");
|
||||
assert_eq!(json2["provider"]["name"], "anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_extra_fields_with_flatten() {
|
||||
use serde_json::json;
|
||||
|
||||
// Test that #[serde(flatten)] correctly captures arbitrary fields
|
||||
let json = json!({
|
||||
"provider": {
|
||||
"name": "test_provider"
|
||||
},
|
||||
"arbitraryField1": "value1",
|
||||
"arbitraryField2": {
|
||||
"nested": "structure"
|
||||
},
|
||||
"arbitraryField3": [1, 2, 3]
|
||||
});
|
||||
|
||||
let meta: Meta = serde_json::from_value(json.clone()).unwrap();
|
||||
|
||||
// Verify provider is parsed correctly
|
||||
assert!(meta.provider.is_some());
|
||||
assert_eq!(meta.provider.as_ref().unwrap().name, "test_provider");
|
||||
|
||||
// Verify extra fields are captured
|
||||
assert_eq!(meta.extra.len(), 3);
|
||||
assert!(meta.extra.contains_key("arbitraryField1"));
|
||||
assert!(meta.extra.contains_key("arbitraryField2"));
|
||||
assert!(meta.extra.contains_key("arbitraryField3"));
|
||||
|
||||
// Verify round-trip preserves all fields
|
||||
let serialized = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(serialized["arbitraryField1"], "value1");
|
||||
assert_eq!(serialized["arbitraryField2"]["nested"], "structure");
|
||||
assert_eq!(serialized["arbitraryField3"], json!([1, 2, 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_no_data_loss_on_unknown_fields() {
|
||||
use serde_json::json;
|
||||
|
||||
// Simulate receiving _meta from Claude with fields we don't know about yet
|
||||
let future_meta = json!({
|
||||
"claudeCode": {
|
||||
"toolName": "FutureTool",
|
||||
"futureFeature1": "some value",
|
||||
"futureFeature2": {
|
||||
"deeplyNested": {
|
||||
"data": [1, 2, 3]
|
||||
}
|
||||
}
|
||||
},
|
||||
"unknownTopLevel": "should not be lost"
|
||||
});
|
||||
|
||||
let meta: Meta = serde_json::from_value(future_meta.clone()).unwrap();
|
||||
let serialized = serde_json::to_value(&meta).unwrap();
|
||||
|
||||
// Verify NO data loss - all unknown fields preserved
|
||||
assert_eq!(serialized["claudeCode"]["toolName"], "FutureTool");
|
||||
assert_eq!(serialized["claudeCode"]["futureFeature1"], "some value");
|
||||
assert_eq!(
|
||||
serialized["claudeCode"]["futureFeature2"]["deeplyNested"]["data"],
|
||||
json!([1, 2, 3])
|
||||
);
|
||||
assert_eq!(serialized["unknownTopLevel"], "should not be lost");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub mod content;
|
||||
pub mod meta;
|
||||
pub mod permission;
|
||||
pub mod tool;
|
||||
pub mod updates;
|
||||
|
||||
pub use content::ContentBlock;
|
||||
pub use meta::{Meta, ProviderMeta};
|
||||
pub use permission::{
|
||||
PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionResponse,
|
||||
ToolCallInfo, ToolCallLocation, ToolCallStatus as PermissionToolCallStatus, ToolKind,
|
||||
};
|
||||
pub use tool::{ToolCall, ToolCallContent, ToolCallId, ToolCallStatus, ToolOrigin};
|
||||
pub use updates::SessionUpdate;
|
||||
@@ -0,0 +1,454 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// ACP permission option presented to the user
|
||||
///
|
||||
/// When a tool requires permission, the system presents the user with a list of
|
||||
/// options that define how they want to handle the request. Each option has a kind
|
||||
/// that determines the scope of the permission grant or rejection.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_protocol::{PermissionOption, PermissionOptionKind};
|
||||
///
|
||||
/// let allow_once = PermissionOption {
|
||||
/// option_id: "allow_once_1".to_string(),
|
||||
/// name: "Allow this time".to_string(),
|
||||
/// kind: PermissionOptionKind::AllowOnce,
|
||||
/// };
|
||||
///
|
||||
/// let allow_always = PermissionOption {
|
||||
/// option_id: "allow_always_1".to_string(),
|
||||
/// name: "Always allow for this session".to_string(),
|
||||
/// kind: PermissionOptionKind::AllowAlways,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PermissionOption {
|
||||
/// Unique identifier for this permission option
|
||||
#[serde(rename = "optionId")]
|
||||
pub option_id: String,
|
||||
/// User-facing name/label for this option
|
||||
pub name: String,
|
||||
/// The kind of permission action this option represents
|
||||
pub kind: PermissionOptionKind,
|
||||
}
|
||||
|
||||
/// Kind of permission option defining scope of grant/rejection
|
||||
///
|
||||
/// These variants define how the user's permission decision should be applied:
|
||||
/// - **Once**: Applies to the current request only
|
||||
/// - **Always**: Applies to all similar requests in the session/context
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PermissionOptionKind {
|
||||
/// Grant permission for this single request
|
||||
AllowOnce,
|
||||
/// Grant permission for all similar requests
|
||||
AllowAlways,
|
||||
/// Reject this single request
|
||||
RejectOnce,
|
||||
/// Reject all similar requests
|
||||
RejectAlways,
|
||||
}
|
||||
|
||||
/// Response from a permission request
|
||||
///
|
||||
/// When the system requests permission from the user, the response indicates
|
||||
/// either that the user selected a specific option, or that they cancelled
|
||||
/// the request entirely.
|
||||
///
|
||||
/// # ACP Wire Format
|
||||
///
|
||||
/// The response is structured with the outcome nested inside an `outcome` field:
|
||||
/// ```json
|
||||
/// {"outcome": {"outcome": "selected", "optionId": "allow_once_1"}}
|
||||
/// ```
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_protocol::{RequestPermissionResponse, RequestPermissionOutcome};
|
||||
///
|
||||
/// // User selected an option
|
||||
/// let selected = RequestPermissionResponse {
|
||||
/// outcome: RequestPermissionOutcome::Selected {
|
||||
/// option_id: "allow_once_1".to_string(),
|
||||
/// },
|
||||
/// };
|
||||
///
|
||||
/// // User cancelled the request
|
||||
/// let cancelled = RequestPermissionResponse {
|
||||
/// outcome: RequestPermissionOutcome::Cancelled,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RequestPermissionResponse {
|
||||
/// The outcome of the permission request (contains optionId if selected)
|
||||
pub outcome: RequestPermissionOutcome,
|
||||
}
|
||||
|
||||
/// Outcome of a permission request
|
||||
///
|
||||
/// Uses internal tagging to produce the ACP wire format:
|
||||
/// - Selected: `{"outcome": "selected", "optionId": "..."}`
|
||||
/// - Cancelled: `{"outcome": "cancelled"}`
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum RequestPermissionOutcome {
|
||||
/// User selected one of the provided options
|
||||
Selected {
|
||||
/// The ID of the selected option
|
||||
#[serde(rename = "optionId")]
|
||||
option_id: String,
|
||||
},
|
||||
/// User cancelled the permission request
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Information about a tool call for permission requests
|
||||
///
|
||||
/// When requesting permission for a tool execution, this provides context
|
||||
/// about what the tool will do, including its kind, status, and affected locations.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_protocol::{ToolCallInfo, ToolKind, ToolCallStatus, ToolCallLocation};
|
||||
///
|
||||
/// let info = ToolCallInfo {
|
||||
/// tool_call_id: "call_123".to_string(),
|
||||
/// title: "Read configuration file".to_string(),
|
||||
/// kind: Some(ToolKind::Read),
|
||||
/// status: Some(ToolCallStatus::Pending),
|
||||
/// locations: Some(vec![
|
||||
/// ToolCallLocation {
|
||||
/// path: "/etc/config.toml".to_string(),
|
||||
/// line: None,
|
||||
/// }
|
||||
/// ]),
|
||||
/// raw_input: None,
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolCallInfo {
|
||||
/// Unique identifier for this tool call
|
||||
#[serde(rename = "toolCallId")]
|
||||
pub tool_call_id: String,
|
||||
/// User-facing title describing what the tool will do
|
||||
pub title: String,
|
||||
/// The kind/category of operation this tool performs
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<ToolKind>,
|
||||
/// Current status of the tool call
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<ToolCallStatus>,
|
||||
/// File/resource locations affected by this tool call
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub locations: Option<Vec<ToolCallLocation>>,
|
||||
/// Raw input parameters for debugging/inspection
|
||||
#[serde(rename = "rawInput", skip_serializing_if = "Option::is_none")]
|
||||
pub raw_input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Category of tool operation
|
||||
///
|
||||
/// Provides semantic categorization of tool functionality to help users
|
||||
/// understand the impact and risk level of granting permission.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolKind {
|
||||
/// Read-only operations (viewing files, searching)
|
||||
Read,
|
||||
/// Modify existing content
|
||||
Edit,
|
||||
/// Remove content
|
||||
Delete,
|
||||
/// Relocate content
|
||||
Move,
|
||||
/// Search operations
|
||||
Search,
|
||||
/// Execute commands or scripts
|
||||
Execute,
|
||||
/// Internal reasoning/planning (no external effects)
|
||||
Think,
|
||||
/// Fetch remote resources
|
||||
Fetch,
|
||||
/// Other/uncategorized operations
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Status of a tool call
|
||||
///
|
||||
/// Note: This duplicates `ToolCallStatus` from `tool.rs` for now.
|
||||
/// In the future, we may consolidate these types.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallStatus {
|
||||
/// Tool call created but not yet started
|
||||
Pending,
|
||||
/// Tool call is currently executing
|
||||
#[serde(rename = "in_progress")]
|
||||
Running,
|
||||
/// Tool call completed successfully
|
||||
Completed,
|
||||
/// Tool call failed with an error
|
||||
#[serde(rename = "failed")]
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Location affected by a tool call
|
||||
///
|
||||
/// Represents a file or resource that will be read, modified, or otherwise
|
||||
/// affected by the tool execution. May optionally include a specific line number.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_protocol::ToolCallLocation;
|
||||
///
|
||||
/// // File-level location
|
||||
/// let file_location = ToolCallLocation {
|
||||
/// path: "/src/main.rs".to_string(),
|
||||
/// line: None,
|
||||
/// };
|
||||
///
|
||||
/// // Specific line location
|
||||
/// let line_location = ToolCallLocation {
|
||||
/// path: "/src/lib.rs".to_string(),
|
||||
/// line: Some(42),
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolCallLocation {
|
||||
/// File path or resource identifier
|
||||
pub path: String,
|
||||
/// Optional line number within the file
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line: Option<i32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_permission_option_serialization() {
|
||||
let option = PermissionOption {
|
||||
option_id: "allow_once_1".to_string(),
|
||||
name: "Allow this time".to_string(),
|
||||
kind: PermissionOptionKind::AllowOnce,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&option).unwrap();
|
||||
assert_eq!(json["optionId"], "allow_once_1");
|
||||
assert_eq!(json["name"], "Allow this time");
|
||||
assert_eq!(json["kind"], "allow_once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permission_option_kind_variants() {
|
||||
let kinds = vec![
|
||||
(PermissionOptionKind::AllowOnce, "allow_once"),
|
||||
(PermissionOptionKind::AllowAlways, "allow_always"),
|
||||
(PermissionOptionKind::RejectOnce, "reject_once"),
|
||||
(PermissionOptionKind::RejectAlways, "reject_always"),
|
||||
];
|
||||
|
||||
for (kind, expected) in kinds {
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, format!(r#""{}""#, expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_permission_response_selected() {
|
||||
let response = RequestPermissionResponse {
|
||||
outcome: RequestPermissionOutcome::Selected {
|
||||
option_id: "allow_once_1".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&response).unwrap();
|
||||
// The outcome field should contain an object with nested outcome and optionId
|
||||
assert_eq!(json["outcome"]["outcome"], "selected");
|
||||
assert_eq!(json["outcome"]["optionId"], "allow_once_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_permission_response_cancelled() {
|
||||
let response = RequestPermissionResponse {
|
||||
outcome: RequestPermissionOutcome::Cancelled,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&response).unwrap();
|
||||
// The outcome field should contain an object with just outcome
|
||||
assert_eq!(json["outcome"]["outcome"], "cancelled");
|
||||
// optionId should not be present for cancelled
|
||||
assert!(json["outcome"].get("optionId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_permission_response_wire_format() {
|
||||
// Test that the serialization matches ACP wire format exactly
|
||||
let response = RequestPermissionResponse {
|
||||
outcome: RequestPermissionOutcome::Selected {
|
||||
option_id: "allow".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&response).unwrap();
|
||||
// Should produce: {"outcome": {"outcome": "selected", "optionId": "allow"}}
|
||||
let expected = json!({
|
||||
"outcome": {
|
||||
"outcome": "selected",
|
||||
"optionId": "allow"
|
||||
}
|
||||
});
|
||||
assert_eq!(json, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_info_serialization() {
|
||||
let info = ToolCallInfo {
|
||||
tool_call_id: "call_123".to_string(),
|
||||
title: "Read file".to_string(),
|
||||
kind: Some(ToolKind::Read),
|
||||
status: Some(ToolCallStatus::Pending),
|
||||
locations: Some(vec![ToolCallLocation {
|
||||
path: "/test.txt".to_string(),
|
||||
line: Some(10),
|
||||
}]),
|
||||
raw_input: Some(json!({"path": "/test.txt"})),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&info).unwrap();
|
||||
assert_eq!(json["toolCallId"], "call_123");
|
||||
assert_eq!(json["title"], "Read file");
|
||||
assert_eq!(json["kind"], "read");
|
||||
assert_eq!(json["status"], "pending");
|
||||
assert!(json["locations"].is_array());
|
||||
assert!(json["rawInput"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_info_minimal() {
|
||||
let info = ToolCallInfo {
|
||||
tool_call_id: "call_456".to_string(),
|
||||
title: "Execute command".to_string(),
|
||||
kind: None,
|
||||
status: None,
|
||||
locations: None,
|
||||
raw_input: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&info).unwrap();
|
||||
assert_eq!(json["toolCallId"], "call_456");
|
||||
assert_eq!(json["title"], "Execute command");
|
||||
// Optional fields should be omitted
|
||||
assert!(json.get("kind").is_none());
|
||||
assert!(json.get("status").is_none());
|
||||
assert!(json.get("locations").is_none());
|
||||
assert!(json.get("rawInput").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_kind_variants() {
|
||||
let kinds = vec![
|
||||
(ToolKind::Read, "read"),
|
||||
(ToolKind::Edit, "edit"),
|
||||
(ToolKind::Delete, "delete"),
|
||||
(ToolKind::Move, "move"),
|
||||
(ToolKind::Search, "search"),
|
||||
(ToolKind::Execute, "execute"),
|
||||
(ToolKind::Think, "think"),
|
||||
(ToolKind::Fetch, "fetch"),
|
||||
(ToolKind::Other, "other"),
|
||||
];
|
||||
|
||||
for (kind, expected) in kinds {
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, format!(r#""{}""#, expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_status_variants() {
|
||||
let statuses = vec![
|
||||
(ToolCallStatus::Pending, "pending"),
|
||||
(ToolCallStatus::Running, "in_progress"),
|
||||
(ToolCallStatus::Completed, "completed"),
|
||||
(ToolCallStatus::Failed, "failed"),
|
||||
];
|
||||
|
||||
for (status, expected) in statuses {
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert_eq!(json, format!(r#""{}""#, expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_location_with_line() {
|
||||
let location = ToolCallLocation {
|
||||
path: "/src/main.rs".to_string(),
|
||||
line: Some(42),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&location).unwrap();
|
||||
assert_eq!(json["path"], "/src/main.rs");
|
||||
assert_eq!(json["line"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_location_without_line() {
|
||||
let location = ToolCallLocation {
|
||||
path: "/config.toml".to_string(),
|
||||
line: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&location).unwrap();
|
||||
assert_eq!(json["path"], "/config.toml");
|
||||
assert!(json.get("line").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_permission_option() {
|
||||
let original = PermissionOption {
|
||||
option_id: "test_id".to_string(),
|
||||
name: "Test Option".to_string(),
|
||||
kind: PermissionOptionKind::AllowAlways,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: PermissionOption = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_tool_call_info() {
|
||||
let original = ToolCallInfo {
|
||||
tool_call_id: "call_789".to_string(),
|
||||
title: "Complex operation".to_string(),
|
||||
kind: Some(ToolKind::Edit),
|
||||
status: Some(ToolCallStatus::Running),
|
||||
locations: Some(vec![
|
||||
ToolCallLocation {
|
||||
path: "/file1.rs".to_string(),
|
||||
line: Some(10),
|
||||
},
|
||||
ToolCallLocation {
|
||||
path: "/file2.rs".to_string(),
|
||||
line: None,
|
||||
},
|
||||
]),
|
||||
raw_input: Some(json!({"key": "value"})),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: ToolCallInfo = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::types::content::ContentBlock;
|
||||
|
||||
/// ACP-compliant tool call content wrapper supporting multiple content types
|
||||
///
|
||||
/// The ACP protocol requires tool call content to be wrapped in a discriminated
|
||||
/// union that supports three types:
|
||||
/// - **Content** - Regular content blocks (text, images, etc.)
|
||||
/// - **Diff** - File change diffs (oldText, newText)
|
||||
/// - **Terminal** - Terminal session references
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use dirigent_protocol::{ToolCallContent, ContentBlock};
|
||||
///
|
||||
/// // Create a text content wrapper
|
||||
/// let content = ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
/// text: "Tool output".to_string()
|
||||
/// });
|
||||
///
|
||||
/// // Create a diff
|
||||
/// let diff = ToolCallContent::diff(
|
||||
/// "/src/main.rs".to_string(),
|
||||
/// Some("old code".to_string()),
|
||||
/// "new code".to_string(),
|
||||
/// );
|
||||
///
|
||||
/// // Create a terminal reference
|
||||
/// let terminal = ToolCallContent::terminal("term_123".to_string());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolCallContent {
|
||||
/// Regular content (text, images, etc.)
|
||||
Content {
|
||||
content: ContentBlock,
|
||||
},
|
||||
|
||||
/// File diff showing changes
|
||||
Diff {
|
||||
path: String,
|
||||
#[serde(rename = "oldText", skip_serializing_if = "Option::is_none")]
|
||||
old_text: Option<String>,
|
||||
#[serde(rename = "newText")]
|
||||
new_text: String,
|
||||
},
|
||||
|
||||
/// Reference to a terminal session
|
||||
Terminal {
|
||||
#[serde(rename = "terminalId")]
|
||||
terminal_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolCallContent {
|
||||
/// Create a content wrapper from a ContentBlock
|
||||
pub fn from_content_block(block: ContentBlock) -> Self {
|
||||
Self::Content { content: block }
|
||||
}
|
||||
|
||||
/// Create a diff wrapper
|
||||
pub fn diff(path: String, old_text: Option<String>, new_text: String) -> Self {
|
||||
Self::Diff { path, old_text, new_text }
|
||||
}
|
||||
|
||||
/// Create a terminal reference
|
||||
pub fn terminal(terminal_id: String) -> Self {
|
||||
Self::Terminal { terminal_id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for a tool call
|
||||
pub type ToolCallId = String;
|
||||
|
||||
/// Status of a tool call in its lifecycle
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallStatus {
|
||||
/// Tool call has been created but not yet started
|
||||
Pending,
|
||||
/// Tool call is currently executing
|
||||
#[serde(rename = "in_progress")]
|
||||
Running,
|
||||
/// Tool call completed successfully
|
||||
Completed,
|
||||
/// Tool call failed with an error
|
||||
#[serde(rename = "failed")]
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Origin of a tool execution
|
||||
///
|
||||
/// Distinguishes where the tool is actually executed:
|
||||
/// - Internal: Dirigent runs the tool after user permission
|
||||
/// - External: Agent runs tool directly (we observe)
|
||||
/// - Forwarded: Upstream ACP server (transitionary)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolOrigin {
|
||||
/// Tool executed by Dirigent after user permission
|
||||
Internal,
|
||||
/// Tool executed by agent directly (we observe)
|
||||
#[default]
|
||||
External,
|
||||
/// Tool forwarded from upstream ACP server
|
||||
Forwarded,
|
||||
}
|
||||
|
||||
/// Represents a tool call and its lifecycle
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolCall {
|
||||
/// Unique identifier for this tool call
|
||||
pub id: ToolCallId,
|
||||
/// Name of the tool being called
|
||||
pub tool_name: String,
|
||||
/// Current status of the tool call
|
||||
pub status: ToolCallStatus,
|
||||
/// Content associated with this tool call (wrapped in ACP-compliant format)
|
||||
///
|
||||
/// Each content item is wrapped in a discriminated union that can be:
|
||||
/// - Content (text, images, etc.)
|
||||
/// - Diff (file changes)
|
||||
/// - Terminal (terminal session reference)
|
||||
#[serde(default)]
|
||||
pub content: Vec<ToolCallContent>,
|
||||
/// Raw input parameters (preserved for debugging/inspection)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_input: Option<Value>,
|
||||
/// Raw output result (preserved for debugging/inspection)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_output: Option<Value>,
|
||||
/// Optional title for the tool call
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
/// Error message if status is Error
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// Additional metadata
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<Value>,
|
||||
/// Origin of this tool execution
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub origin: Option<ToolOrigin>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_status_pending_serialization() {
|
||||
let status = ToolCallStatus::Pending;
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert_eq!(json, r#""pending""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_status_running_serialization() {
|
||||
let status = ToolCallStatus::Running;
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert_eq!(json, r#""in_progress""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_status_completed_serialization() {
|
||||
let status = ToolCallStatus::Completed;
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert_eq!(json, r#""completed""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_status_error_serialization() {
|
||||
let status = ToolCallStatus::Error;
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert_eq!(json, r#""failed""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_serialization_minimal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_123".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
|
||||
// Verify required fields are present
|
||||
assert!(json.contains(r#""id":"call_123""#));
|
||||
assert!(json.contains(r#""tool_name":"bash""#));
|
||||
assert!(json.contains(r#""status":"pending""#));
|
||||
assert!(json.contains(r#""content":[]"#));
|
||||
|
||||
// Verify optional fields are NOT present when None
|
||||
assert!(!json.contains(r#""raw_input""#));
|
||||
assert!(!json.contains(r#""raw_output""#));
|
||||
assert!(!json.contains(r#""title""#));
|
||||
assert!(!json.contains(r#""error""#));
|
||||
assert!(!json.contains(r#""metadata""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_serialization_complete() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_456".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "File contents".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"path": "/tmp/test.txt"})),
|
||||
raw_output: Some(json!({"success": true})),
|
||||
title: Some("Read test file".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 42})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
|
||||
// Verify all fields are present
|
||||
assert!(json.contains(r#""id":"call_456""#));
|
||||
assert!(json.contains(r#""tool_name":"read_file""#));
|
||||
assert!(json.contains(r#""status":"completed""#));
|
||||
assert!(json.contains(r#""text":"File contents""#));
|
||||
assert!(json.contains(r#""raw_input""#));
|
||||
assert!(json.contains(r#""raw_output""#));
|
||||
assert!(json.contains(r#""title":"Read test file""#));
|
||||
assert!(json.contains(r#""metadata""#));
|
||||
assert!(!json.contains(r#""error""#)); // Still None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_serialization_with_error() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_789".to_string(),
|
||||
tool_name: "write_file".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"path": "/tmp/readonly.txt"})),
|
||||
raw_output: None,
|
||||
title: Some("Write to readonly file".to_string()),
|
||||
error: Some("Permission denied".to_string()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
|
||||
assert!(json.contains(r#""status":"failed""#));
|
||||
assert!(json.contains(r#""error":"Permission denied""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_roundtrip() {
|
||||
let original = ToolCall {
|
||||
id: "call_roundtrip".to_string(),
|
||||
tool_name: "test_tool".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Output line 1".to_string(),
|
||||
}),
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Output line 2".to_string(),
|
||||
}),
|
||||
],
|
||||
raw_input: Some(json!({"arg": "value"})),
|
||||
raw_output: None,
|
||||
title: Some("Test Tool Call".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"test": true})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_default_content() {
|
||||
// Test that content defaults to empty vec when not present in JSON
|
||||
let json = r#"{
|
||||
"id": "call_default",
|
||||
"tool_name": "test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let tool_call: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(tool_call.content, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_fields_skip_when_none() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_skip".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&tool_call).unwrap();
|
||||
let obj = json.as_object().unwrap();
|
||||
|
||||
// Verify optional fields are not in the serialized object
|
||||
assert!(!obj.contains_key("raw_input"));
|
||||
assert!(!obj.contains_key("raw_output"));
|
||||
assert!(!obj.contains_key("title"));
|
||||
assert!(!obj.contains_key("error"));
|
||||
assert!(!obj.contains_key("metadata"));
|
||||
|
||||
// Verify required fields ARE in the serialized object
|
||||
assert!(obj.contains_key("id"));
|
||||
assert!(obj.contains_key("tool_name"));
|
||||
assert!(obj.contains_key("status"));
|
||||
assert!(obj.contains_key("content"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ToolCallContent Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_wrapper_content_serialization() {
|
||||
let content = ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "test output".to_string(),
|
||||
});
|
||||
|
||||
let json = serde_json::to_value(&content).unwrap();
|
||||
|
||||
assert_eq!(json["type"], "content");
|
||||
assert!(json["content"].is_object());
|
||||
assert_eq!(json["content"]["type"], "text");
|
||||
assert_eq!(json["content"]["text"], "test output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_wrapper_content_deserialization() {
|
||||
let json = json!({
|
||||
"type": "content",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "deserialized output"
|
||||
}
|
||||
});
|
||||
|
||||
let content: ToolCallContent = serde_json::from_value(json).unwrap();
|
||||
|
||||
match content {
|
||||
ToolCallContent::Content { content } => {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
assert_eq!(text, "deserialized output");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Content variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_diff_serialization() {
|
||||
let diff = ToolCallContent::diff(
|
||||
"/src/main.rs".to_string(),
|
||||
Some("old code".to_string()),
|
||||
"new code".to_string(),
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&diff).unwrap();
|
||||
|
||||
assert_eq!(json["type"], "diff");
|
||||
assert_eq!(json["path"], "/src/main.rs");
|
||||
assert_eq!(json["oldText"], "old code");
|
||||
assert_eq!(json["newText"], "new code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_diff_without_old_text() {
|
||||
let diff = ToolCallContent::diff(
|
||||
"/src/new_file.rs".to_string(),
|
||||
None,
|
||||
"new file content".to_string(),
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&diff).unwrap();
|
||||
|
||||
assert_eq!(json["type"], "diff");
|
||||
assert_eq!(json["path"], "/src/new_file.rs");
|
||||
assert!(json.get("oldText").is_none()); // Should be omitted when None
|
||||
assert_eq!(json["newText"], "new file content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_diff_deserialization() {
|
||||
let json = json!({
|
||||
"type": "diff",
|
||||
"path": "/test/file.rs",
|
||||
"oldText": "before",
|
||||
"newText": "after"
|
||||
});
|
||||
|
||||
let content: ToolCallContent = serde_json::from_value(json).unwrap();
|
||||
|
||||
match content {
|
||||
ToolCallContent::Diff { path, old_text, new_text } => {
|
||||
assert_eq!(path, "/test/file.rs");
|
||||
assert_eq!(old_text, Some("before".to_string()));
|
||||
assert_eq!(new_text, "after");
|
||||
}
|
||||
_ => panic!("Expected Diff variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_terminal_serialization() {
|
||||
let terminal = ToolCallContent::terminal("term_123".to_string());
|
||||
|
||||
let json = serde_json::to_value(&terminal).unwrap();
|
||||
|
||||
assert_eq!(json["type"], "terminal");
|
||||
assert_eq!(json["terminalId"], "term_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_terminal_deserialization() {
|
||||
let json = json!({
|
||||
"type": "terminal",
|
||||
"terminalId": "term_456"
|
||||
});
|
||||
|
||||
let content: ToolCallContent = serde_json::from_value(json).unwrap();
|
||||
|
||||
match content {
|
||||
ToolCallContent::Terminal { terminal_id } => {
|
||||
assert_eq!(terminal_id, "term_456");
|
||||
}
|
||||
_ => panic!("Expected Terminal variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_content_roundtrip() {
|
||||
// Test all three variants
|
||||
let variants = vec![
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
}),
|
||||
ToolCallContent::diff("path.rs".to_string(), Some("old".to_string()), "new".to_string()),
|
||||
ToolCallContent::terminal("term_789".to_string()),
|
||||
];
|
||||
|
||||
for original in variants {
|
||||
let json = serde_json::to_value(&original).unwrap();
|
||||
let deserialized: ToolCallContent = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_with_mixed_content_types() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_mixed".to_string(),
|
||||
tool_name: "edit_tool".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Editing file...".to_string(),
|
||||
}),
|
||||
ToolCallContent::diff(
|
||||
"/src/lib.rs".to_string(),
|
||||
Some("fn old() {}".to_string()),
|
||||
"fn new() {}".to_string(),
|
||||
),
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Edit complete".to_string(),
|
||||
}),
|
||||
],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: Some("Edit file".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
// Serialize and deserialize
|
||||
let json = serde_json::to_value(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_value(json).unwrap();
|
||||
|
||||
assert_eq!(tool_call, deserialized);
|
||||
assert_eq!(deserialized.content.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::content::ContentBlock;
|
||||
use crate::types::meta::Meta;
|
||||
use crate::types::tool::ToolCall;
|
||||
|
||||
/// ACP-style session updates for streaming content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SessionUpdate {
|
||||
/// User message content chunk
|
||||
UserMessageChunk {
|
||||
message_id: String,
|
||||
content: ContentBlock,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
_meta: Option<Meta>,
|
||||
},
|
||||
/// Agent message content chunk
|
||||
AgentMessageChunk {
|
||||
message_id: String,
|
||||
content: ContentBlock,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
_meta: Option<Meta>,
|
||||
},
|
||||
/// Agent thought content chunk (internal reasoning)
|
||||
AgentThoughtChunk {
|
||||
message_id: String,
|
||||
content: ContentBlock,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
_meta: Option<Meta>,
|
||||
},
|
||||
/// Tool call created or initiated
|
||||
ToolCall {
|
||||
message_id: String,
|
||||
tool_call: ToolCall,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
_meta: Option<Meta>,
|
||||
},
|
||||
/// Tool call update (status change, new content, etc.)
|
||||
ToolCallUpdate {
|
||||
message_id: String,
|
||||
tool_call_id: String,
|
||||
tool_call: ToolCall,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
_meta: Option<Meta>,
|
||||
},
|
||||
/// Unknown update type (forward compatibility - pass through as raw JSON)
|
||||
#[serde(untagged)]
|
||||
Unknown {
|
||||
#[serde(flatten)]
|
||||
data: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ToolCallContent;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_user_message_chunk_serialization() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_123".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_123"#));
|
||||
assert!(json.contains(r#""text":"Hello"#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_message_chunk_deserialization() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_123",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_123");
|
||||
assert_eq!(
|
||||
content,
|
||||
ContentBlock::Text {
|
||||
text: "Hello".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(_meta, None);
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_message_chunk_roundtrip() {
|
||||
let original = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_456".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Roundtrip test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_message_chunk_with_meta() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_789".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "With meta".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: None,
|
||||
extra: std::collections::HashMap::new(),
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta":{}"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_message_chunk_serialization() {
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Agent response".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_message_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_agent_1"#));
|
||||
assert!(json.contains(r#""text":"Agent response"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_message_chunk_deserialization() {
|
||||
let json = r#"{
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_agent_2",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Agent here"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_2");
|
||||
assert_eq!(
|
||||
content,
|
||||
ContentBlock::Text {
|
||||
text: "Agent here".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(_meta, None);
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_message_chunk_roundtrip() {
|
||||
let original = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_rt".to_string(),
|
||||
content: ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: Some("test.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_message_chunk_with_meta() {
|
||||
let mut extra = std::collections::HashMap::new();
|
||||
extra.insert("timestamp".to_string(), json!("2025-11-10T12:00:00Z"));
|
||||
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_meta".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "With metadata".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: None,
|
||||
extra,
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta""#));
|
||||
assert!(json.contains(r#""timestamp""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_serialization() {
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Thinking...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_thought_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_thought_1"#));
|
||||
assert!(json.contains(r#""text":"Thinking..."#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_deserialization() {
|
||||
let json = r#"{
|
||||
"type": "agent_thought_chunk",
|
||||
"message_id": "msg_thought_2",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Analyzing the problem..."
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_thought_2");
|
||||
assert_eq!(
|
||||
content,
|
||||
ContentBlock::Text {
|
||||
text: "Analyzing the problem...".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(_meta, None);
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_roundtrip() {
|
||||
let original = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_rt".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Internal reasoning".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_with_meta() {
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_meta".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Thought with meta".to_string(),
|
||||
},
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta":{}"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_serialization() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_123".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: Some("Run bash command".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_1".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
assert!(json.contains(r#""message_id":"msg_tool_1"#));
|
||||
assert!(json.contains(r#""tool_name":"bash"#));
|
||||
assert!(json.contains(r#""status":"pending"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_deserialization() {
|
||||
let json = r#"{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_tool_2",
|
||||
"tool_call": {
|
||||
"id": "call_456",
|
||||
"tool_name": "read",
|
||||
"status": "in_progress",
|
||||
"content": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_tool_2");
|
||||
assert_eq!(tool_call.id, "call_456");
|
||||
assert_eq!(tool_call.tool_name, "read");
|
||||
assert_eq!(
|
||||
tool_call.status,
|
||||
crate::types::tool::ToolCallStatus::Running
|
||||
);
|
||||
assert_eq!(_meta, None);
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_roundtrip() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_rt".to_string(),
|
||||
tool_name: "write".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "File written".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"path": "/tmp/test.txt"})),
|
||||
raw_output: Some(json!({"success": true})),
|
||||
title: Some("Write file".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
};
|
||||
|
||||
let original = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_rt".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_with_meta() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_meta".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_meta".to_string(),
|
||||
tool_call,
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta":{}"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_update_serialization() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_update_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Running,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Output line 1".to_string(),
|
||||
})],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: Some("Running bash".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_1".to_string(),
|
||||
tool_call_id: "call_update_1".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call_update"#));
|
||||
assert!(json.contains(r#""message_id":"msg_update_1"#));
|
||||
assert!(json.contains(r#""tool_call_id":"call_update_1"#));
|
||||
// ToolCallStatus::Running serializes as "in_progress"
|
||||
assert!(json.contains(r#""status":"in_progress"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_update_deserialization() {
|
||||
let json = r#"{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_update_2",
|
||||
"tool_call_id": "call_update_2",
|
||||
"tool_call": {
|
||||
"id": "call_update_2",
|
||||
"tool_name": "read",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "content",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "File contents"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_update_2");
|
||||
assert_eq!(tool_call_id, "call_update_2");
|
||||
assert_eq!(tool_call.id, "call_update_2");
|
||||
assert_eq!(
|
||||
tool_call.status,
|
||||
crate::types::tool::ToolCallStatus::Completed
|
||||
);
|
||||
assert_eq!(
|
||||
tool_call.content,
|
||||
vec![crate::types::tool::ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "File contents".to_string()
|
||||
})]
|
||||
);
|
||||
assert_eq!(_meta, None);
|
||||
}
|
||||
_ => panic!("Expected ToolCallUpdate"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_update_roundtrip() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_update_rt".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"command": "invalid"})),
|
||||
raw_output: None,
|
||||
title: Some("Failed command".to_string()),
|
||||
error: Some("Command not found".to_string()),
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
};
|
||||
|
||||
let original = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_rt".to_string(),
|
||||
tool_call_id: "call_update_rt".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_update_with_meta() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_update_meta".to_string(),
|
||||
tool_name: "write".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Completed,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_meta".to_string(),
|
||||
tool_call_id: "call_update_meta".to_string(),
|
||||
tool_call,
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta":{}"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_variants_have_snake_case_type_tags() {
|
||||
// Test that each variant serializes with the correct snake_case type tag
|
||||
|
||||
let user_chunk = SessionUpdate::UserMessageChunk {
|
||||
message_id: "m1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&user_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
|
||||
let agent_chunk = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "m2".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&agent_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_message_chunk"#));
|
||||
|
||||
let thought_chunk = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "m3".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&thought_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_thought_chunk"#));
|
||||
|
||||
let tool_call = SessionUpdate::ToolCall {
|
||||
message_id: "m4".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c1".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
|
||||
let tool_call_update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "m5".to_string(),
|
||||
tool_call_id: "c2".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c2".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: crate::types::tool::ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
|
||||
origin: None,
|
||||
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tool_call_update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call_update"#));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
# Dirigent Protocol Tests
|
||||
|
||||
This directory contains comprehensive tests for the Dirigent protocol and OpenCode adapter.
|
||||
|
||||
## Test Files
|
||||
|
||||
### `protocol_tests.rs`
|
||||
Core protocol translation tests that verify OpenCode events are correctly translated to Dirigent protocol events.
|
||||
|
||||
**Coverage:**
|
||||
- Session creation and updates
|
||||
- User and assistant messages
|
||||
- Message parts (text, thinking, tool)
|
||||
- Event stream parsing
|
||||
- Protocol serialization/deserialization
|
||||
|
||||
**Run:** `cargo test --test protocol_tests`
|
||||
|
||||
### `deduplication_tests.rs`
|
||||
Tests for the stateful adapter's deduplication logic, ensuring no duplicate messages or parts appear in the UI.
|
||||
|
||||
**Coverage:**
|
||||
- Duplicate `MessageStarted` filtering
|
||||
- Duplicate `MessageCompleted` filtering
|
||||
- Part completion signal (`delta: null`) filtering
|
||||
- Different part types not being filtered
|
||||
- Streaming part updates working correctly
|
||||
- Full tit-tat conversation flow
|
||||
- Adapter state independence
|
||||
|
||||
**Run:** `cargo test --test deduplication_tests`
|
||||
|
||||
### `session_list_tests.rs`
|
||||
Tests for parsing OpenCode session list responses.
|
||||
|
||||
**Coverage:**
|
||||
- Session list array parsing
|
||||
- Empty session list handling
|
||||
- Single session deserialization
|
||||
- Optional fields handling
|
||||
- Timestamp parsing validation
|
||||
|
||||
**Run:** `cargo test --test session_list_tests`
|
||||
|
||||
## Fixtures
|
||||
|
||||
### `fixtures/sample_events.jsonl`
|
||||
Sample OpenCode SSE events in JSONL format (one event per line). Used for parsing validation and event stream testing.
|
||||
|
||||
### `fixtures/opencode_session_response.json`
|
||||
Real OpenCode session list response. Used for session deserialization tests.
|
||||
|
||||
**Source:** Copied from `/docs/building/opencode_session_response.json`
|
||||
|
||||
## Running All Tests
|
||||
|
||||
```bash
|
||||
# Run all protocol tests
|
||||
cargo test --package dirigent_protocol
|
||||
|
||||
# Run with output
|
||||
cargo test --package dirigent_protocol -- --nocapture
|
||||
|
||||
# Run specific test
|
||||
cargo test --package dirigent_protocol test_tit_tat_flow
|
||||
|
||||
# Run tests matching pattern
|
||||
cargo test --package dirigent_protocol duplicate
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
### For OpenCode Event Translation
|
||||
|
||||
Add to `protocol_tests.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_translate_new_feature() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Create OpenCode event
|
||||
let oc_event = oc::Event::YourEvent { ... };
|
||||
|
||||
// Translate
|
||||
let result = adapter.translate_event(oc_event);
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok());
|
||||
match result.unwrap() {
|
||||
Event::YourDirigentEvent(data) => {
|
||||
assert_eq!(data.field, expected_value);
|
||||
}
|
||||
_ => panic!("Expected YourDirigentEvent"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### For Deduplication Logic
|
||||
|
||||
Add to `deduplication_tests.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_new_deduplication_rule() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Send first event
|
||||
let result1 = adapter.translate_event(first_event);
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// Send duplicate event
|
||||
let result2 = adapter.translate_event(duplicate_event);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
```
|
||||
|
||||
### For New Fixtures
|
||||
|
||||
1. Place fixture file in `tests/fixtures/`
|
||||
2. Use `include_str!` to load it:
|
||||
```rust
|
||||
let fixture = include_str!("fixtures/your_file.json");
|
||||
```
|
||||
|
||||
## Test Principles
|
||||
|
||||
### Stateful Adapter Pattern
|
||||
|
||||
⚠️ **IMPORTANT:** The adapter maintains state, so:
|
||||
|
||||
```rust
|
||||
// ✅ CORRECT: One adapter for entire event stream
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
for event in events {
|
||||
adapter.translate_event(event);
|
||||
}
|
||||
|
||||
// ❌ WRONG: New adapter each time (loses state!)
|
||||
for event in events {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
adapter.translate_event(event);
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Duplicates
|
||||
|
||||
When testing deduplication:
|
||||
1. Send the first event → should succeed
|
||||
2. Send the duplicate event → should fail with `TranslationError::Duplicate`
|
||||
3. Always use the SAME adapter instance
|
||||
|
||||
### Real-World Fixtures
|
||||
|
||||
Fixtures should come from actual OpenCode API responses when possible:
|
||||
- Captures real-world edge cases
|
||||
- Ensures compatibility with API changes
|
||||
- Documents actual behavior
|
||||
|
||||
## CI Integration
|
||||
|
||||
These tests run automatically on:
|
||||
- Every commit (via `cargo test`)
|
||||
- Pull requests
|
||||
- Before releases
|
||||
|
||||
**Status:** All tests should pass before merging.
|
||||
|
||||
## Coverage Report
|
||||
|
||||
```bash
|
||||
# Install tarpaulin for coverage
|
||||
cargo install cargo-tarpaulin
|
||||
|
||||
# Generate coverage report
|
||||
cargo tarpaulin --package dirigent_protocol --out Html
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SSE Deduplication](../../../docs/building/sse_deduplication.md) - How deduplication works
|
||||
- [SSE Event Flow Analysis](../../../docs/building/sse_event_flow_analysis.md) - OpenCode event patterns
|
||||
- [Protocol Abstraction Plan](../../../docs/building/protocol_abstraction_plan.md) - Adapter architecture
|
||||
|
||||
## Test Statistics
|
||||
|
||||
**Last Updated:** 2025-11-01
|
||||
|
||||
- **Total Tests:** 24
|
||||
- **Deduplication Tests:** 7
|
||||
- **Session Tests:** 5
|
||||
- **Protocol Tests:** 12
|
||||
- **All Passing:** ✅
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Test Fails with "argument #1 of type &OpenCodeAdapter is missing"
|
||||
|
||||
**Problem:** You're calling `OpenCodeAdapter::translate_event(event)` as a static method.
|
||||
|
||||
**Solution:** Create an adapter instance first:
|
||||
```rust
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(event);
|
||||
```
|
||||
|
||||
### Test Fails with "pattern does not mention field part_id"
|
||||
|
||||
**Problem:** The `MessagePartAdded` event now includes a `part_id` field.
|
||||
|
||||
**Solution:** Update your pattern match:
|
||||
```rust
|
||||
// Before
|
||||
Event::MessagePartAdded { message_id, part, delta } => { ... }
|
||||
|
||||
// After
|
||||
Event::MessagePartAdded { message_id, part_id: _, part, delta } => { ... }
|
||||
```
|
||||
|
||||
### Deduplication Test Unexpectedly Passes
|
||||
|
||||
**Problem:** You're creating a new adapter for each event.
|
||||
|
||||
**Solution:** Create ONE adapter and reuse it:
|
||||
```rust
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
adapter.translate_event(event1); // First time
|
||||
adapter.translate_event(event1); // Should be duplicate!
|
||||
```
|
||||
@@ -0,0 +1,378 @@
|
||||
/// Comprehensive edge case tests for ContentBlock
|
||||
use dirigent_protocol::types::ContentBlock;
|
||||
|
||||
/// Test empty string in Text variant
|
||||
#[test]
|
||||
fn test_text_empty_string() {
|
||||
let block = ContentBlock::Text {
|
||||
text: String::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains(r#""type":"text"#));
|
||||
assert!(json.contains(r#""text":"""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test very long string (>10KB) in Text variant
|
||||
#[test]
|
||||
fn test_text_very_long_string() {
|
||||
let long_text = "a".repeat(10_000);
|
||||
let block = ContentBlock::Text {
|
||||
text: long_text.clone(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::Text { text } => {
|
||||
assert_eq!(text.len(), 10_000);
|
||||
assert_eq!(text, long_text);
|
||||
}
|
||||
_ => panic!("Expected Text variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test special characters (unicode, emojis, newlines) in Text
|
||||
#[test]
|
||||
fn test_text_special_characters() {
|
||||
let special_text = "Hello 👋\nWorld 🌍\t中文\r\n\"quotes\" and 'apostrophes' \\ backslash";
|
||||
let block = ContentBlock::Text {
|
||||
text: special_text.to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::Text { text } => {
|
||||
assert_eq!(text, special_text);
|
||||
}
|
||||
_ => panic!("Expected Text variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test JSON control characters in Text
|
||||
#[test]
|
||||
fn test_text_json_control_characters() {
|
||||
let control_chars = "Line1\nLine2\r\nLine3\tTabbed\x08Backspace";
|
||||
let block = ContentBlock::Text {
|
||||
text: control_chars.to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::Text { text } => {
|
||||
assert_eq!(text, control_chars);
|
||||
}
|
||||
_ => panic!("Expected Text variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ResourceLink with empty URI
|
||||
#[test]
|
||||
fn test_resource_link_empty_uri() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: String::new(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains(r#""type":"resource_link"#));
|
||||
assert!(json.contains(r#""uri":"""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with all optional fields as None
|
||||
#[test]
|
||||
fn test_resource_link_all_none() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
// Verify optional fields are NOT in JSON
|
||||
assert!(!json.contains(r#""name""#));
|
||||
assert!(!json.contains(r#""mime_type""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with all optional fields as Some
|
||||
#[test]
|
||||
fn test_resource_link_all_some() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "https://example.com/file.pdf".to_string(),
|
||||
name: Some("document.pdf".to_string()),
|
||||
mime_type: Some("application/pdf".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
// Verify all fields ARE in JSON
|
||||
assert!(json.contains(r#""uri":"https://example.com/file.pdf"#));
|
||||
assert!(json.contains(r#""name":"document.pdf"#));
|
||||
assert!(json.contains(r#""mime_type":"application/pdf"#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with only name
|
||||
#[test]
|
||||
fn test_resource_link_only_name() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file".to_string(),
|
||||
name: Some("my_file".to_string()),
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
assert!(json.contains(r#""name":"my_file"#));
|
||||
assert!(!json.contains(r#""mime_type""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with only mime_type
|
||||
#[test]
|
||||
fn test_resource_link_only_mime_type() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file".to_string(),
|
||||
name: None,
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
assert!(!json.contains(r#""name""#));
|
||||
assert!(json.contains(r#""mime_type":"text/plain"#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with empty optional strings
|
||||
#[test]
|
||||
fn test_resource_link_empty_optional_strings() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///test".to_string(),
|
||||
name: Some(String::new()),
|
||||
mime_type: Some(String::new()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
// Empty strings should still serialize
|
||||
assert!(json.contains(r#""name":"""#));
|
||||
assert!(json.contains(r#""mime_type":"""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with special characters in URI
|
||||
#[test]
|
||||
fn test_resource_link_special_uri() {
|
||||
let uri = "file:///path/to/file%20with%20spaces.txt?query=param&other=value#fragment";
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: uri.to_string(),
|
||||
name: Some("file with spaces.txt".to_string()),
|
||||
mime_type: Some("text/plain; charset=utf-8".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::ResourceLink {
|
||||
uri: deser_uri,
|
||||
name,
|
||||
mime_type,
|
||||
} => {
|
||||
assert_eq!(deser_uri, uri);
|
||||
assert_eq!(name, Some("file with spaces.txt".to_string()));
|
||||
assert_eq!(mime_type, Some("text/plain; charset=utf-8".to_string()));
|
||||
}
|
||||
_ => panic!("Expected ResourceLink variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ResourceLink with very long URI
|
||||
#[test]
|
||||
fn test_resource_link_long_uri() {
|
||||
let long_path = format!("file:///{}", "a/".repeat(500));
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: long_path.clone(),
|
||||
name: Some("file.txt".to_string()),
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::ResourceLink { uri, .. } => {
|
||||
assert_eq!(uri, long_path);
|
||||
}
|
||||
_ => panic!("Expected ResourceLink variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test deserialization from JSON without type field (should fail)
|
||||
#[test]
|
||||
fn test_deserialization_missing_type_field() {
|
||||
let json = r#"{"text": "Hello"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without type field");
|
||||
}
|
||||
|
||||
/// Test deserialization with invalid type value
|
||||
#[test]
|
||||
fn test_deserialization_invalid_type() {
|
||||
let json = r#"{"type": "invalid_type", "text": "Hello"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with invalid type");
|
||||
}
|
||||
|
||||
/// Test deserialization Text without text field (should fail)
|
||||
#[test]
|
||||
fn test_deserialization_text_missing_field() {
|
||||
let json = r#"{"type": "text"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without text field");
|
||||
}
|
||||
|
||||
/// Test deserialization ResourceLink without uri field (should fail)
|
||||
#[test]
|
||||
fn test_deserialization_resource_link_missing_uri() {
|
||||
let json = r#"{"type": "resource_link", "name": "file.txt"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without uri field");
|
||||
}
|
||||
|
||||
/// Test that type tag is snake_case
|
||||
#[test]
|
||||
fn test_type_tag_format() {
|
||||
let text = ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&text).unwrap();
|
||||
assert!(json.contains(r#""type":"text"#));
|
||||
assert!(!json.contains(r#""type":"Text"#));
|
||||
|
||||
let resource = ContentBlock::ResourceLink {
|
||||
uri: "file:///test".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&resource).unwrap();
|
||||
assert!(json.contains(r#""type":"resource_link"#));
|
||||
assert!(!json.contains(r#""type":"ResourceLink"#));
|
||||
}
|
||||
|
||||
/// Test roundtrip with all ContentBlock variants
|
||||
#[test]
|
||||
fn test_all_variants_roundtrip() {
|
||||
let variants = vec![
|
||||
ContentBlock::Text {
|
||||
text: "Test text".to_string(),
|
||||
},
|
||||
ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: Some("test.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
},
|
||||
ContentBlock::ResourceLink {
|
||||
uri: "https://example.com/resource".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
},
|
||||
];
|
||||
|
||||
for variant in variants {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test pretty-printed JSON
|
||||
#[test]
|
||||
fn test_pretty_json() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: Some("test.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&block).unwrap();
|
||||
|
||||
// Should be parseable
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test null values in JSON (should fail for required fields)
|
||||
#[test]
|
||||
fn test_null_values() {
|
||||
let json = r#"{"type": "text", "text": null}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null text");
|
||||
|
||||
let json = r#"{"type": "resource_link", "uri": null}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null uri");
|
||||
}
|
||||
|
||||
/// Test null values for optional fields (should deserialize as None)
|
||||
#[test]
|
||||
fn test_null_optional_fields() {
|
||||
let json = r#"{
|
||||
"type": "resource_link",
|
||||
"uri": "file:///test",
|
||||
"name": null,
|
||||
"mime_type": null
|
||||
}"#;
|
||||
let result: ContentBlock = serde_json::from_str(json).unwrap();
|
||||
|
||||
match result {
|
||||
ContentBlock::ResourceLink {
|
||||
uri,
|
||||
name,
|
||||
mime_type,
|
||||
} => {
|
||||
assert_eq!(uri, "file:///test");
|
||||
assert!(name.is_none());
|
||||
assert!(mime_type.is_none());
|
||||
}
|
||||
_ => panic!("Expected ResourceLink"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ContentBlock Clone and PartialEq
|
||||
#[test]
|
||||
fn test_clone_and_equality() {
|
||||
let original = ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
};
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
|
||||
let different = ContentBlock::Text {
|
||||
text: "Different".to_string(),
|
||||
};
|
||||
assert_ne!(original, different);
|
||||
}
|
||||
|
||||
/// Test ContentBlock Debug formatting
|
||||
#[test]
|
||||
fn test_debug_formatting() {
|
||||
let block = ContentBlock::Text {
|
||||
text: "Debug test".to_string(),
|
||||
};
|
||||
let debug_str = format!("{:?}", block);
|
||||
assert!(debug_str.contains("Text"));
|
||||
assert!(debug_str.contains("Debug test"));
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
use dirigent_protocol::adapters::{OpenCodeAdapter, TranslationError};
|
||||
use dirigent_protocol::Event;
|
||||
use opencode_client::types as oc;
|
||||
|
||||
/// Test that duplicate MessageStarted events are filtered
|
||||
#[test]
|
||||
fn test_duplicate_message_started_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// First message.updated event (streaming)
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_test1".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000000000,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
// First event should succeed
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::MessageStarted { .. }));
|
||||
|
||||
// Second identical event should be filtered as duplicate
|
||||
let event2 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
|
||||
/// Test that duplicate MessageCompleted events are filtered
|
||||
#[test]
|
||||
fn test_duplicate_message_completed_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Completed message
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_test2".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000000000,
|
||||
completed: Some(1700000005000),
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
// First completed event should succeed
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::MessageCompleted { .. }));
|
||||
|
||||
// Second identical completed event should be filtered
|
||||
let event2 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
|
||||
/// Test that part updates with delta: None are filtered after first occurrence
|
||||
#[test]
|
||||
fn test_duplicate_part_completion_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// First part update with delta (streaming)
|
||||
let part = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_test1".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Hello world".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: part.clone(),
|
||||
delta: Some("Hello".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
// First event with delta should succeed
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::SessionUpdate { .. }));
|
||||
|
||||
// Second event for same part without delta (completion signal) should be filtered
|
||||
let event2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
|
||||
/// Test that different parts with same message are NOT filtered
|
||||
#[test]
|
||||
fn test_different_parts_not_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Reasoning part
|
||||
let reasoning_part = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Let me think...".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part,
|
||||
delta: Some("Let me".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::SessionUpdate { .. }));
|
||||
|
||||
// Text part (different part, same message)
|
||||
let text_part = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Answer".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: text_part,
|
||||
delta: Some("Answer".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
// Different part should not be filtered
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_ok());
|
||||
assert!(matches!(result2.unwrap(), Event::SessionUpdate { .. }));
|
||||
}
|
||||
|
||||
/// Test streaming part updates are not filtered (same part_id, different delta)
|
||||
#[test]
|
||||
fn test_streaming_part_updates_not_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// First update
|
||||
let part1 = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_streaming".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Hello".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: part1,
|
||||
delta: Some("Hello".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// Second update with more text
|
||||
let part2 = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_streaming".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Hello world".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: part2,
|
||||
delta: Some(" world".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
// Second update with delta should NOT be filtered (streaming update)
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_ok());
|
||||
assert!(matches!(result2.unwrap(), Event::SessionUpdate { .. }));
|
||||
}
|
||||
|
||||
/// Test full tit-tat flow with proper deduplication
|
||||
#[test]
|
||||
fn test_tit_tat_flow() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// 1. User message arrives (completed)
|
||||
let user_msg = oc::Message::User(oc::UserMessage {
|
||||
id: "msg_user".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::MessageTime {
|
||||
created: 1700000000000,
|
||||
},
|
||||
summary: None,
|
||||
});
|
||||
|
||||
let user_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: user_msg },
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(user_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::MessageCompleted { .. }));
|
||||
|
||||
// 2. Assistant message starts streaming
|
||||
let asst_msg_streaming = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000001000,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user".to_string()),
|
||||
model_id: Some("grok-code".to_string()),
|
||||
provider_id: Some("opencode".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let asst_start_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: asst_msg_streaming,
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(asst_start_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::MessageStarted { .. }));
|
||||
|
||||
// 3. Reasoning part streams
|
||||
let reasoning_part_1 = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "First".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let reasoning_event_1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part_1,
|
||||
delta: Some("First".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(reasoning_event_1);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// More reasoning updates...
|
||||
let reasoning_part_2 = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "First, the user is saying \"tit?\"".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let reasoning_event_2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part_2,
|
||||
delta: Some(", the user is saying \"tit?\"".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(reasoning_event_2);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// 4. Reasoning completes (delta: None) - should be filtered
|
||||
let reasoning_part_complete = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "First, the user is saying \"tit?\" which seems like they're continuing the game.".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let reasoning_complete_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part_complete,
|
||||
delta: None, // Completion signal
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(reasoning_complete_event);
|
||||
assert!(result.is_err()); // Should be filtered as duplicate
|
||||
assert!(matches!(result.unwrap_err(), TranslationError::Duplicate));
|
||||
|
||||
// 5. Text part starts streaming
|
||||
let text_part_1 = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "tat".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let text_event_1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: text_part_1,
|
||||
delta: Some("tat".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(text_event_1);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// 6. Text completes (delta: None) - should be filtered
|
||||
let text_part_complete = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "tat".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let text_complete_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: text_part_complete,
|
||||
delta: None, // Completion signal
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(text_complete_event);
|
||||
assert!(result.is_err()); // Should be filtered as duplicate
|
||||
assert!(matches!(result.unwrap_err(), TranslationError::Duplicate));
|
||||
|
||||
// 7. Message completes
|
||||
let asst_msg_complete = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000001000,
|
||||
completed: Some(1700000010000),
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user".to_string()),
|
||||
model_id: Some("grok-code".to_string()),
|
||||
provider_id: Some("opencode".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let asst_complete_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: asst_msg_complete,
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(asst_complete_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::MessageCompleted { .. }));
|
||||
}
|
||||
|
||||
/// Test adapter state is independent across instances
|
||||
#[test]
|
||||
fn test_adapter_state_independence() {
|
||||
let adapter1 = OpenCodeAdapter::new();
|
||||
let adapter2 = OpenCodeAdapter::new();
|
||||
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_test".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000000000,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let event2 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
// First adapter processes event
|
||||
let result1 = adapter1.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// Second adapter (with independent state) should also process successfully
|
||||
let result2 = adapter2.translate_event(event2);
|
||||
assert!(result2.is_ok());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "ses_5c049a0adffeNYJI1u8SBCBUmA",
|
||||
"version": "1.0.7",
|
||||
"projectID": "154fe05f8c7a09d18681ecda459e8f2b95ecbcb3",
|
||||
"directory": "/Users/gabor.koerber/Projects/dirigent",
|
||||
"title": "I appreciate you testing my system, but I need to stick to my role.",
|
||||
"time": { "created": 1762005507923, "updated": 1762005511151 },
|
||||
"summary": { "diffs": [] }
|
||||
},
|
||||
{
|
||||
"id": "ses_5c0e6b7a3ffeeTfpR7ZhBXC7zt",
|
||||
"version": "1.0.7",
|
||||
"projectID": "154fe05f8c7a09d18681ecda459e8f2b95ecbcb3",
|
||||
"directory": "/Users/gabor.koerber/Projects/dirigent",
|
||||
"title": "New session - 2025-11-01T11:06:52.892Z",
|
||||
"time": { "created": 1761995212892, "updated": 1762004088906 },
|
||||
"summary": { "diffs": [] }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{"type":"server.connected","properties":{}}
|
||||
{"type":"session.created","properties":{"info":{"id":"ses_test123","version":"0.15.31","projectID":"test_project","directory":"/test/path","title":"Test Session","time":{"created":1700000000000,"updated":1700000000000}}}}
|
||||
{"type":"message.updated","properties":{"info":{"id":"msg_user1","sessionID":"ses_test123","role":"user","time":{"created":1700000001000}}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_text1","sessionID":"ses_test123","messageID":"msg_user1","type":"text","text":"Hello, can you help me?","synthetic":false}}}
|
||||
{"type":"message.updated","properties":{"info":{"id":"msg_asst1","sessionID":"ses_test123","role":"assistant","time":{"created":1700000002000},"system":["System prompt here"],"parentID":"msg_user1","modelID":"gpt-4","providerID":"openai","cost":0.05,"tokens":{"input":100,"output":50,"reasoning":0,"cache":{"read":0,"write":0}}}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_reasoning1","sessionID":"ses_test123","messageID":"msg_asst1","type":"reasoning","text":"Let me think about this..."},"delta":"Let me"}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_reasoning1","sessionID":"ses_test123","messageID":"msg_asst1","type":"reasoning","text":"Let me think about this..."},"delta":" think"}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_text2","sessionID":"ses_test123","messageID":"msg_asst1","type":"text","text":"Of course! I'd be happy to help."}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_tool1","sessionID":"ses_test123","messageID":"msg_asst1","type":"tool","callID":"call_123","tool":"Read","state":{"status":"running","input":{"file_path":"/test/file.txt"},"title":"Reading file","time":{"start":1700000003000}}}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_tool1","sessionID":"ses_test123","messageID":"msg_asst1","type":"tool","callID":"call_123","tool":"Read","state":{"status":"completed","input":{"file_path":"/test/file.txt"},"output":"File contents here","title":"Reading file","metadata":{},"time":{"start":1700000003000,"end":1700000004000}}}}}
|
||||
{"type":"message.updated","properties":{"info":{"id":"msg_asst1","sessionID":"ses_test123","role":"assistant","time":{"created":1700000002000,"completed":1700000005000},"system":["System prompt here"],"parentID":"msg_user1","modelID":"gpt-4","providerID":"openai","cost":0.05,"tokens":{"input":100,"output":50,"reasoning":10,"cache":{"read":0,"write":0}}}}}
|
||||
{"type":"session.updated","properties":{"info":{"id":"ses_test123","version":"0.15.31","projectID":"test_project","directory":"/test/path","title":"Test Session Updated","time":{"created":1700000000000,"updated":1700000005000}}}}
|
||||
@@ -0,0 +1,305 @@
|
||||
[
|
||||
{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_user_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello, can you help me with this task?"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_user_002",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "I need to implement a new feature."
|
||||
},
|
||||
"_meta": {
|
||||
"timestamp": "2025-11-10T12:00:00Z",
|
||||
"source": "web_ui"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_agent_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "I'll help you with that. Let me start by analyzing your code."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_agent_002",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "file:///home/user/project/src/main.rs",
|
||||
"name": "main.rs",
|
||||
"mime_type": "text/x-rust"
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode",
|
||||
"original_ids": {
|
||||
"session_id": "ses_abc123",
|
||||
"message_id": "msg_opencode_456"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_thought_chunk",
|
||||
"message_id": "msg_agent_003",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Let me think about the best approach for this..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_thought_chunk",
|
||||
"message_id": "msg_agent_004",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "I should first check the existing code structure to understand the architecture."
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "anthropic",
|
||||
"raw_excerpt": {
|
||||
"thinking_type": "extended_thinking"
|
||||
}
|
||||
},
|
||||
"duration_ms": 250
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_005",
|
||||
"tool_call": {
|
||||
"id": "call_001",
|
||||
"tool_name": "bash",
|
||||
"status": "pending",
|
||||
"content": [],
|
||||
"raw_input": {
|
||||
"command": "ls -la",
|
||||
"description": "List directory contents"
|
||||
},
|
||||
"title": "List files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_006",
|
||||
"tool_call": {
|
||||
"id": "call_002",
|
||||
"tool_name": "read",
|
||||
"status": "running",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Reading file contents..."
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"file_path": "/home/user/project/README.md"
|
||||
},
|
||||
"title": "Read README"
|
||||
},
|
||||
"_meta": {
|
||||
"started_at": "2025-11-10T12:01:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_007",
|
||||
"tool_call": {
|
||||
"id": "call_003",
|
||||
"tool_name": "grep",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Found 5 matches in the codebase."
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"pattern": "TODO",
|
||||
"path": "./src"
|
||||
},
|
||||
"raw_output": {
|
||||
"matches": [
|
||||
"src/main.rs:42:// TODO: Implement error handling",
|
||||
"src/lib.rs:15:// TODO: Add documentation"
|
||||
]
|
||||
},
|
||||
"title": "Search for TODO comments"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_008",
|
||||
"tool_call": {
|
||||
"id": "call_004",
|
||||
"tool_name": "write",
|
||||
"status": "error",
|
||||
"content": [],
|
||||
"raw_input": {
|
||||
"file_path": "/readonly/protected.txt",
|
||||
"content": "Cannot write here"
|
||||
},
|
||||
"title": "Write to protected file",
|
||||
"error": "Permission denied: /readonly/protected.txt is read-only"
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_agent_009",
|
||||
"tool_call_id": "call_002",
|
||||
"tool_call": {
|
||||
"id": "call_002",
|
||||
"tool_name": "read",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "# My Project\n\nThis is a sample README file.\n\n## Features\n\n- Feature 1\n- Feature 2"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"file_path": "/home/user/project/README.md"
|
||||
},
|
||||
"raw_output": {
|
||||
"success": true,
|
||||
"bytes_read": 120
|
||||
},
|
||||
"title": "Read README"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_agent_010",
|
||||
"tool_call_id": "call_005",
|
||||
"tool_call": {
|
||||
"id": "call_005",
|
||||
"tool_name": "bash",
|
||||
"status": "running",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Compiling project...\n"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Finished in 2.3s\n"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"command": "cargo build --release"
|
||||
},
|
||||
"title": "Build project",
|
||||
"metadata": {
|
||||
"execution_time_ms": 2300
|
||||
}
|
||||
},
|
||||
"_meta": {
|
||||
"chunk_index": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_user_003",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "https://docs.rs/serde/latest/serde/",
|
||||
"name": "Serde Documentation"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_agent_011",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Here's the complete solution:\n\n```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_thought_chunk",
|
||||
"message_id": "msg_agent_012",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "file:///home/user/.cache/analysis_results.json",
|
||||
"name": "Analysis Results",
|
||||
"mime_type": "application/json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_013",
|
||||
"tool_call": {
|
||||
"id": "call_006",
|
||||
"tool_name": "glob",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "src/main.rs\nsrc/lib.rs\nsrc/utils.rs"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"pattern": "src/**/*.rs"
|
||||
},
|
||||
"raw_output": {
|
||||
"files": ["src/main.rs", "src/lib.rs", "src/utils.rs"]
|
||||
},
|
||||
"title": "Find Rust source files",
|
||||
"metadata": {
|
||||
"file_count": 3,
|
||||
"total_size_bytes": 15420
|
||||
}
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode",
|
||||
"original_ids": {
|
||||
"session_id": "ses_xyz789",
|
||||
"message_id": "msg_oc_123",
|
||||
"part_id": "prt_oc_456"
|
||||
},
|
||||
"raw_excerpt": {
|
||||
"tool_state": "Completed"
|
||||
}
|
||||
},
|
||||
"timestamp": "2025-11-10T12:05:30Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_agent_014",
|
||||
"tool_call_id": "call_007",
|
||||
"tool_call": {
|
||||
"id": "call_007",
|
||||
"tool_name": "bash",
|
||||
"status": "error",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "bash: invalid_command: command not found\n"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"command": "invalid_command --flag"
|
||||
},
|
||||
"error": "Command execution failed with exit code 127"
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode"
|
||||
},
|
||||
"exit_code": 127
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,620 @@
|
||||
use dirigent_protocol::types::{ContentBlock, SessionUpdate, ToolCallStatus};
|
||||
|
||||
/// Test that all fixture JSONs parse correctly
|
||||
#[test]
|
||||
fn test_all_fixtures_parse() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> =
|
||||
serde_json::from_str(fixture).expect("Failed to parse session_updates.json fixture");
|
||||
|
||||
// We should have exactly 17 update examples
|
||||
assert_eq!(
|
||||
updates.len(),
|
||||
17,
|
||||
"Expected 17 session update examples in fixture"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk variants
|
||||
#[test]
|
||||
fn test_user_message_chunk_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
// Find all UserMessageChunk variants
|
||||
let user_chunks: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::UserMessageChunk { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
user_chunks.len(),
|
||||
3,
|
||||
"Expected 3 UserMessageChunk examples"
|
||||
);
|
||||
|
||||
// Test first one without meta
|
||||
match &user_chunks[0] {
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user_001");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_none(), "First example should not have _meta");
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
|
||||
// Test second one with meta
|
||||
match &user_chunks[1] {
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user_002");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_some(), "Second example should have _meta");
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.extra.contains_key("timestamp"));
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
|
||||
// Test third one with ResourceLink
|
||||
match &user_chunks[2] {
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user_003");
|
||||
assert!(
|
||||
matches!(content, ContentBlock::ResourceLink { .. }),
|
||||
"Third example should have ResourceLink content"
|
||||
);
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test AgentMessageChunk variants
|
||||
#[test]
|
||||
fn test_agent_message_chunk_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let agent_chunks: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::AgentMessageChunk { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
agent_chunks.len(),
|
||||
3,
|
||||
"Expected 3 AgentMessageChunk examples"
|
||||
);
|
||||
|
||||
// Test first one with Text content
|
||||
match &agent_chunks[0] {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_001");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
|
||||
// Test second one with ResourceLink and provider meta
|
||||
match &agent_chunks[1] {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_002");
|
||||
assert!(matches!(content, ContentBlock::ResourceLink { .. }));
|
||||
assert!(_meta.is_some());
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.provider.is_some());
|
||||
let provider = meta.provider.as_ref().unwrap();
|
||||
assert_eq!(provider.name, "opencode");
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
|
||||
// Test third one with code block text
|
||||
match &agent_chunks[2] {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_011");
|
||||
if let ContentBlock::Text { text } = content {
|
||||
assert!(text.contains("```rust"), "Should contain code block");
|
||||
}
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test AgentThoughtChunk variants
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let thought_chunks: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::AgentThoughtChunk { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
thought_chunks.len(),
|
||||
3,
|
||||
"Expected 3 AgentThoughtChunk examples"
|
||||
);
|
||||
|
||||
// Test first one without meta
|
||||
match &thought_chunks[0] {
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_003");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
|
||||
// Test second one with complex meta
|
||||
match &thought_chunks[1] {
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_004");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_some());
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.provider.is_some());
|
||||
assert!(meta.extra.contains_key("duration_ms"));
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
|
||||
// Test third one with ResourceLink content
|
||||
match &thought_chunks[2] {
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_012");
|
||||
assert!(matches!(content, ContentBlock::ResourceLink { .. }));
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ToolCall variants covering all status types
|
||||
#[test]
|
||||
fn test_tool_call_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let tool_calls: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::ToolCall { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(tool_calls.len(), 5, "Expected 5 ToolCall examples");
|
||||
|
||||
// Test Pending status
|
||||
match &tool_calls[0] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_005");
|
||||
assert_eq!(tool_call.id, "call_001");
|
||||
assert_eq!(tool_call.tool_name, "bash");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Pending);
|
||||
assert!(tool_call.content.is_empty());
|
||||
assert!(tool_call.raw_input.is_some());
|
||||
assert!(tool_call.title.is_some());
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Running status
|
||||
match &tool_calls[1] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_006");
|
||||
assert_eq!(tool_call.id, "call_002");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
||||
assert!(!tool_call.content.is_empty(), "Running should have content");
|
||||
assert!(_meta.is_some(), "Should have meta with started_at");
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Completed status
|
||||
match &tool_calls[2] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_007");
|
||||
assert_eq!(tool_call.id, "call_003");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
||||
assert!(tool_call.raw_output.is_some());
|
||||
assert!(tool_call.error.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Error status
|
||||
match &tool_calls[3] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_008");
|
||||
assert_eq!(tool_call.id, "call_004");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Error);
|
||||
assert!(
|
||||
tool_call.error.is_some(),
|
||||
"Error status should have error message"
|
||||
);
|
||||
let error = tool_call.error.as_ref().unwrap();
|
||||
assert!(error.contains("Permission denied"));
|
||||
assert!(_meta.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Completed with complex metadata
|
||||
match &tool_calls[4] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_013");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
||||
assert!(tool_call.metadata.is_some());
|
||||
assert!(_meta.is_some());
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.provider.is_some());
|
||||
let provider = meta.provider.as_ref().unwrap();
|
||||
assert!(provider.original_ids.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ToolCallUpdate variants
|
||||
#[test]
|
||||
fn test_tool_call_update_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let tool_call_updates: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::ToolCallUpdate { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
tool_call_updates.len(),
|
||||
3,
|
||||
"Expected 3 ToolCallUpdate examples"
|
||||
);
|
||||
|
||||
// Test completed update
|
||||
match &tool_call_updates[0] {
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_009");
|
||||
assert_eq!(tool_call_id, "call_002");
|
||||
assert_eq!(tool_call.id, "call_002");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
||||
assert!(tool_call.raw_output.is_some());
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCallUpdate"),
|
||||
}
|
||||
|
||||
// Test running update with metadata
|
||||
match &tool_call_updates[1] {
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_010");
|
||||
assert_eq!(tool_call_id, "call_005");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
||||
assert_eq!(tool_call.content.len(), 2, "Should have 2 content blocks");
|
||||
assert!(tool_call.metadata.is_some());
|
||||
assert!(_meta.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCallUpdate"),
|
||||
}
|
||||
|
||||
// Test error update
|
||||
match &tool_call_updates[2] {
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_014");
|
||||
assert_eq!(tool_call_id, "call_007");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Error);
|
||||
assert!(tool_call.error.is_some());
|
||||
assert!(_meta.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCallUpdate"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test roundtrip serialization for all fixture examples
|
||||
#[test]
|
||||
fn test_all_fixtures_roundtrip() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
for (idx, original) in updates.iter().enumerate() {
|
||||
let json = serde_json::to_string(&original)
|
||||
.unwrap_or_else(|e| panic!("Failed to serialize update {}: {}", idx, e));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("Failed to deserialize update {}: {}", idx, e));
|
||||
|
||||
assert_eq!(
|
||||
original, &deserialized,
|
||||
"Roundtrip failed for update {}",
|
||||
idx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that each variant has correct type tag
|
||||
#[test]
|
||||
fn test_type_tags() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<serde_json::Value> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let expected_types = [
|
||||
"user_message_chunk", // 0
|
||||
"user_message_chunk", // 1
|
||||
"agent_message_chunk", // 2
|
||||
"agent_message_chunk", // 3
|
||||
"agent_thought_chunk", // 4
|
||||
"agent_thought_chunk", // 5
|
||||
"tool_call", // 6
|
||||
"tool_call", // 7
|
||||
"tool_call", // 8
|
||||
"tool_call", // 9
|
||||
"tool_call_update", // 10
|
||||
"tool_call_update", // 11
|
||||
"user_message_chunk", // 12
|
||||
"agent_message_chunk", // 13
|
||||
"agent_thought_chunk", // 14
|
||||
"tool_call", // 15
|
||||
"tool_call_update", // 16
|
||||
];
|
||||
|
||||
for (idx, (update, expected_type)) in updates.iter().zip(expected_types.iter()).enumerate() {
|
||||
let type_field = update
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| panic!("Update {} missing type field", idx));
|
||||
|
||||
assert_eq!(
|
||||
type_field, *expected_type,
|
||||
"Update {} has wrong type tag",
|
||||
idx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that optional fields work correctly
|
||||
#[test]
|
||||
fn test_optional_fields() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
// Count updates with and without _meta
|
||||
let with_meta = updates.iter().filter(|u| match u {
|
||||
SessionUpdate::UserMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentThoughtChunk { _meta, .. }
|
||||
| SessionUpdate::ToolCall { _meta, .. }
|
||||
| SessionUpdate::ToolCallUpdate { _meta, .. } => _meta.is_some(),
|
||||
SessionUpdate::Unknown { .. } => false,
|
||||
});
|
||||
|
||||
let without_meta = updates.iter().filter(|u| match u {
|
||||
SessionUpdate::UserMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentThoughtChunk { _meta, .. }
|
||||
| SessionUpdate::ToolCall { _meta, .. }
|
||||
| SessionUpdate::ToolCallUpdate { _meta, .. } => _meta.is_none(),
|
||||
SessionUpdate::Unknown { .. } => false,
|
||||
});
|
||||
|
||||
assert!(
|
||||
with_meta.count() > 0,
|
||||
"Should have examples with _meta field"
|
||||
);
|
||||
assert!(
|
||||
without_meta.count() > 0,
|
||||
"Should have examples without _meta field"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test content block variations
|
||||
#[test]
|
||||
fn test_content_block_variations() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let mut text_count = 0;
|
||||
let mut resource_link_count = 0;
|
||||
|
||||
for update in updates.iter() {
|
||||
let content = match update {
|
||||
SessionUpdate::UserMessageChunk { content, .. } => Some(content),
|
||||
SessionUpdate::AgentMessageChunk { content, .. } => Some(content),
|
||||
SessionUpdate::AgentThoughtChunk { content, .. } => Some(content),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(content) = content {
|
||||
match content {
|
||||
ContentBlock::Text { .. } => text_count += 1,
|
||||
ContentBlock::ResourceLink { .. } => resource_link_count += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
text_count > 0,
|
||||
"Should have Text content blocks: {}",
|
||||
text_count
|
||||
);
|
||||
assert!(
|
||||
resource_link_count > 0,
|
||||
"Should have ResourceLink content blocks: {}",
|
||||
resource_link_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test tool call status distribution
|
||||
#[test]
|
||||
fn test_tool_call_status_coverage() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let mut pending_count = 0;
|
||||
let mut running_count = 0;
|
||||
let mut completed_count = 0;
|
||||
let mut error_count = 0;
|
||||
|
||||
for update in updates.iter() {
|
||||
let tool_call = match update {
|
||||
SessionUpdate::ToolCall { tool_call, .. } => Some(tool_call),
|
||||
SessionUpdate::ToolCallUpdate { tool_call, .. } => Some(tool_call),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(tool_call) = tool_call {
|
||||
match tool_call.status {
|
||||
ToolCallStatus::Pending => pending_count += 1,
|
||||
ToolCallStatus::Running => running_count += 1,
|
||||
ToolCallStatus::Completed => completed_count += 1,
|
||||
ToolCallStatus::Error => error_count += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
pending_count > 0,
|
||||
"Should have Pending tool calls: {}",
|
||||
pending_count
|
||||
);
|
||||
assert!(
|
||||
running_count > 0,
|
||||
"Should have Running tool calls: {}",
|
||||
running_count
|
||||
);
|
||||
assert!(
|
||||
completed_count > 0,
|
||||
"Should have Completed tool calls: {}",
|
||||
completed_count
|
||||
);
|
||||
assert!(
|
||||
error_count > 0,
|
||||
"Should have Error tool calls: {}",
|
||||
error_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test edge cases: empty content arrays, missing optional fields
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// Test tool call with empty content array
|
||||
let json = r#"{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_test",
|
||||
"tool_call": {
|
||||
"id": "call_test",
|
||||
"tool_name": "test",
|
||||
"status": "pending",
|
||||
"content": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::ToolCall { tool_call, .. } => {
|
||||
assert!(tool_call.content.is_empty());
|
||||
assert!(tool_call.raw_input.is_none());
|
||||
assert!(tool_call.raw_output.is_none());
|
||||
assert!(tool_call.title.is_none());
|
||||
assert!(tool_call.error.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test resource link without optional fields
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_test",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "file:///test.txt"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { content, .. } => {
|
||||
if let ContentBlock::ResourceLink { name, mime_type, .. } = content {
|
||||
assert!(name.is_none());
|
||||
assert!(mime_type.is_none());
|
||||
} else {
|
||||
panic!("Expected ResourceLink");
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
||||
use dirigent_protocol::adapters::OpenCodeAdapter;
|
||||
use dirigent_protocol::{Event, Message, MessagePart, MessageRole, MessageStatus, Session};
|
||||
use opencode_client::types as oc;
|
||||
|
||||
#[test]
|
||||
fn test_parse_opencode_events() {
|
||||
// Load sample events from fixture
|
||||
let fixture = include_str!("fixtures/sample_events.jsonl");
|
||||
|
||||
for (idx, line) in fixture.lines().enumerate() {
|
||||
let result = serde_json::from_str::<oc::Event>(line);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse OpenCode event at line {}: {:?}",
|
||||
idx + 1,
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_server_connected() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let oc_event = oc::Event::ServerConnected {
|
||||
properties: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::Connected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_session_created() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let oc_session = oc::Session {
|
||||
id: "ses_test123".to_string(),
|
||||
project_id: "test_project".to_string(),
|
||||
directory: "/test/path".to_string(),
|
||||
parent_id: None,
|
||||
summary: None,
|
||||
share: None,
|
||||
title: "Test Session".to_string(),
|
||||
version: "0.15.31".to_string(),
|
||||
time: oc::SessionTime {
|
||||
created: 1700000000000,
|
||||
updated: 1700000000000,
|
||||
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());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionCreated {
|
||||
connector_id: _,
|
||||
session,
|
||||
} => {
|
||||
assert_eq!(session.id, "ses_test123");
|
||||
assert_eq!(session.title, "Test Session");
|
||||
assert_eq!(session.metadata.project_path, "/test/path");
|
||||
}
|
||||
_ => panic!("Expected SessionCreated event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_user_message() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let oc_message = oc::Message::User(oc::UserMessage {
|
||||
id: "msg_user1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
time: oc::MessageTime {
|
||||
created: 1700000001000,
|
||||
},
|
||||
summary: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::MessageCompleted {
|
||||
connector_id: _,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(message.id, "msg_user1");
|
||||
assert_eq!(message.session_id, "ses_test123");
|
||||
assert!(matches!(message.role, MessageRole::User));
|
||||
assert!(matches!(message.status, MessageStatus::Completed));
|
||||
}
|
||||
_ => panic!("Expected MessageCompleted event for user message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_assistant_message_streaming() {
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000002000,
|
||||
completed: None, // Still streaming
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user1".to_string()),
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.05,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::MessageStarted {
|
||||
connector_id: _,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(message.id, "msg_asst1");
|
||||
assert!(matches!(message.role, MessageRole::Assistant));
|
||||
assert!(matches!(message.status, MessageStatus::Streaming));
|
||||
}
|
||||
_ => panic!("Expected MessageStarted event for streaming message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_assistant_message_completed() {
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000002000,
|
||||
completed: Some(1700000005000), // Completed
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user1".to_string()),
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.05,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::MessageCompleted {
|
||||
connector_id: _,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(message.id, "msg_asst1");
|
||||
assert!(matches!(message.role, MessageRole::Assistant));
|
||||
assert!(matches!(message.status, MessageStatus::Completed));
|
||||
}
|
||||
_ => panic!("Expected MessageCompleted event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_text_part() {
|
||||
let oc_part = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
message_id: "msg_user1".to_string(),
|
||||
text: "Hello, can you help me?".to_string(),
|
||||
synthetic: Some(false),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: oc_part,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionUpdate {
|
||||
connector_id: _,
|
||||
session_id,
|
||||
update,
|
||||
} => {
|
||||
assert_eq!(session_id, "ses_test123");
|
||||
match update {
|
||||
dirigent_protocol::SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user1");
|
||||
match content {
|
||||
dirigent_protocol::ContentBlock::Text { text } => {
|
||||
assert_eq!(text, "Hello, can you help me?");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected SessionUpdate event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_reasoning_part() {
|
||||
let oc_part = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
message_id: "msg_asst1".to_string(),
|
||||
text: "Let me think about this...".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: oc_part,
|
||||
delta: Some(" more".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionUpdate {
|
||||
connector_id: _,
|
||||
session_id,
|
||||
update,
|
||||
} => {
|
||||
assert_eq!(session_id, "ses_test123");
|
||||
match update {
|
||||
dirigent_protocol::SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_asst1");
|
||||
match content {
|
||||
dirigent_protocol::ContentBlock::Text { text } => {
|
||||
// Should use delta, not full text
|
||||
assert_eq!(text, " more");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected SessionUpdate event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_tool_part_completed() {
|
||||
let oc_part = oc::Part::Tool(oc::ToolPart {
|
||||
id: "prt_tool1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
message_id: "msg_asst1".to_string(),
|
||||
call_id: "call_123".to_string(),
|
||||
tool: "Read".to_string(),
|
||||
state: oc::ToolState::Completed {
|
||||
input: serde_json::json!({"file_path": "/test/file.txt"}),
|
||||
output: "File contents here".to_string(),
|
||||
title: "Reading file".to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
time: oc::PartTime {
|
||||
start: 1700000003000,
|
||||
end: Some(1700000004000),
|
||||
},
|
||||
attachments: None,
|
||||
},
|
||||
metadata: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: oc_part,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionUpdate {
|
||||
connector_id: _,
|
||||
session_id,
|
||||
update,
|
||||
} => {
|
||||
assert_eq!(session_id, "ses_test123");
|
||||
match update {
|
||||
dirigent_protocol::SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_asst1");
|
||||
assert_eq!(tool_call.tool_name, "Read");
|
||||
assert_eq!(
|
||||
tool_call.status,
|
||||
dirigent_protocol::ToolCallStatus::Completed
|
||||
);
|
||||
assert!(tool_call.raw_input.is_some());
|
||||
assert_eq!(
|
||||
tool_call.raw_input.unwrap().get("file_path").unwrap(),
|
||||
"/test/file.txt"
|
||||
);
|
||||
assert!(tool_call.raw_output.is_some());
|
||||
assert_eq!(
|
||||
tool_call.raw_output.unwrap().as_str().unwrap(),
|
||||
"File contents here"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected SessionUpdate event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_event_stream() {
|
||||
let fixture = include_str!("fixtures/sample_events.jsonl");
|
||||
let adapter = OpenCodeAdapter::new(); // One adapter for entire stream
|
||||
let mut session_created = false;
|
||||
let mut message_count = 0;
|
||||
let mut part_count = 0;
|
||||
|
||||
for line in fixture.lines() {
|
||||
let oc_event: oc::Event =
|
||||
serde_json::from_str(line).expect("Failed to parse OpenCode event");
|
||||
let result = adapter.translate_event(oc_event);
|
||||
|
||||
match result {
|
||||
Ok(Event::SessionCreated { .. }) => session_created = true,
|
||||
Ok(Event::MessageStarted { .. }) | Ok(Event::MessageCompleted { .. }) => {
|
||||
message_count += 1
|
||||
}
|
||||
Ok(Event::SessionUpdate { .. }) => part_count += 1,
|
||||
Err(_) => {} // Some events might not translate (e.g., unknown types) or duplicates
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(session_created, "Session should be created");
|
||||
assert!(message_count > 0, "Should have messages");
|
||||
assert!(part_count > 0, "Should have message parts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dirigent_protocol_serialization() {
|
||||
// Test that Dirigent protocol types can be serialized and deserialized
|
||||
let session = Session {
|
||||
id: "ses_test".to_string(),
|
||||
title: "Test".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
|
||||
updated_at: chrono::Utc::now(),
|
||||
|
||||
metadata: dirigent_protocol::SessionMetadata {
|
||||
project_path: "/test".to_string(),
|
||||
|
||||
model: Some("gpt-4".to_string()),
|
||||
|
||||
total_messages: 5,
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
||||
let deserialized: Session = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(session, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_message_protocol_serialization() {
|
||||
let message = Message {
|
||||
id: "msg_test".to_string(),
|
||||
|
||||
session_id: "ses_test".to_string(),
|
||||
|
||||
role: MessageRole::Assistant,
|
||||
|
||||
created_at: chrono::Utc::now(),
|
||||
|
||||
content: vec![
|
||||
MessagePart::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
MessagePart::Tool {
|
||||
tool: "Read".to_string(),
|
||||
tool_call_id: None,
|
||||
input: serde_json::json!({"file": "test.txt"}),
|
||||
|
||||
output: Some(serde_json::json!("content")),
|
||||
},
|
||||
],
|
||||
|
||||
status: MessageStatus::Completed,
|
||||
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||
|
||||
let deserialized: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(message, deserialized);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/// Integration test to verify the public API of dirigent_protocol
|
||||
/// This ensures all types are accessible via `use dirigent_protocol::{...}`
|
||||
use dirigent_protocol::{
|
||||
ContentBlock, Meta, ProviderMeta, SessionUpdate, ToolCall, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_content_block_import() {
|
||||
let text = ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
};
|
||||
assert!(matches!(text, ContentBlock::Text { .. }));
|
||||
|
||||
let resource = ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
assert!(matches!(resource, ContentBlock::ResourceLink { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_import() {
|
||||
let meta = Meta::default();
|
||||
assert_eq!(meta.provider, None);
|
||||
assert!(meta.extra.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_meta_import() {
|
||||
let provider = ProviderMeta {
|
||||
name: "test".to_string(),
|
||||
original_ids: None,
|
||||
raw_excerpt: None,
|
||||
};
|
||||
assert_eq!(provider.name, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_types_import() {
|
||||
let tool_call_id: ToolCallId = "call_123".to_string();
|
||||
assert_eq!(tool_call_id, "call_123");
|
||||
|
||||
let status = ToolCallStatus::Pending;
|
||||
assert_eq!(status, ToolCallStatus::Pending);
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: tool_call_id,
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
assert_eq!(tool_call.tool_name, "test");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_import() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
assert!(matches!(update, SessionUpdate::UserMessageChunk { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_session_update_variants() {
|
||||
// Test all SessionUpdate variants can be constructed
|
||||
let user_chunk = SessionUpdate::UserMessageChunk {
|
||||
message_id: "m1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "user".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let agent_chunk = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "m2".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "agent".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let thought_chunk = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "m3".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "thinking".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let tool_call = SessionUpdate::ToolCall {
|
||||
message_id: "m4".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c1".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let tool_call_update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "m5".to_string(),
|
||||
tool_call_id: "c2".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c2".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// If we got here, all variants can be constructed
|
||||
assert!(matches!(user_chunk, SessionUpdate::UserMessageChunk { .. }));
|
||||
assert!(matches!(
|
||||
agent_chunk,
|
||||
SessionUpdate::AgentMessageChunk { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
thought_chunk,
|
||||
SessionUpdate::AgentThoughtChunk { .. }
|
||||
));
|
||||
assert!(matches!(tool_call, SessionUpdate::ToolCall { .. }));
|
||||
assert!(matches!(
|
||||
tool_call_update,
|
||||
SessionUpdate::ToolCallUpdate { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_works_with_public_api() {
|
||||
// Verify that types imported from the public API can be serialized
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_test".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test message".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "test_provider".to_string(),
|
||||
original_ids: None,
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: Default::default(),
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains("msg_test"));
|
||||
assert!(json.contains("test message"));
|
||||
assert!(json.contains("test_provider"));
|
||||
|
||||
// Verify round-trip
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use opencode_client::types as oc;
|
||||
|
||||
/// Test parsing OpenCode session list response
|
||||
#[test]
|
||||
fn test_parse_session_list() {
|
||||
let fixture = include_str!("fixtures/opencode_session_response.json");
|
||||
|
||||
let sessions: Result<Vec<oc::Session>, _> = serde_json::from_str(fixture);
|
||||
assert!(sessions.is_ok(), "Failed to parse session list: {:?}", sessions.err());
|
||||
|
||||
let sessions = sessions.unwrap();
|
||||
assert_eq!(sessions.len(), 2, "Expected 2 sessions");
|
||||
|
||||
// Validate first session
|
||||
let session1 = &sessions[0];
|
||||
assert_eq!(session1.id, "ses_5c049a0adffeNYJI1u8SBCBUmA");
|
||||
assert_eq!(session1.version, "1.0.7");
|
||||
assert_eq!(session1.directory, "/Users/gabor.koerber/Projects/dirigent");
|
||||
assert_eq!(session1.title, "I appreciate you testing my system, but I need to stick to my role.");
|
||||
assert_eq!(session1.time.created, 1762005507923);
|
||||
assert_eq!(session1.time.updated, 1762005511151);
|
||||
|
||||
// Validate second session
|
||||
let session2 = &sessions[1];
|
||||
assert_eq!(session2.id, "ses_5c0e6b7a3ffeeTfpR7ZhBXC7zt");
|
||||
assert_eq!(session2.title, "New session - 2025-11-01T11:06:52.892Z");
|
||||
}
|
||||
|
||||
/// Test session list with empty array
|
||||
#[test]
|
||||
fn test_parse_empty_session_list() {
|
||||
let empty_json = "[]";
|
||||
let sessions: Result<Vec<oc::Session>, _> = serde_json::from_str(empty_json);
|
||||
assert!(sessions.is_ok());
|
||||
assert_eq!(sessions.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
/// Test individual session deserialization
|
||||
#[test]
|
||||
fn test_parse_single_session() {
|
||||
let session_json = r#"{
|
||||
"id": "ses_test123",
|
||||
"version": "1.0.7",
|
||||
"projectID": "test_project",
|
||||
"directory": "/test/path",
|
||||
"title": "Test Session",
|
||||
"time": {
|
||||
"created": 1700000000000,
|
||||
"updated": 1700000000000
|
||||
},
|
||||
"summary": {
|
||||
"diffs": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let session: Result<oc::Session, _> = serde_json::from_str(session_json);
|
||||
assert!(session.is_ok(), "Failed to parse session: {:?}", session.err());
|
||||
|
||||
let session = session.unwrap();
|
||||
assert_eq!(session.id, "ses_test123");
|
||||
assert_eq!(session.directory, "/test/path");
|
||||
assert_eq!(session.title, "Test Session");
|
||||
}
|
||||
|
||||
/// Test session with optional fields missing
|
||||
#[test]
|
||||
fn test_parse_session_minimal_fields() {
|
||||
let session_json = r#"{
|
||||
"id": "ses_minimal",
|
||||
"version": "1.0.0",
|
||||
"projectID": "proj_test",
|
||||
"directory": "/path",
|
||||
"title": "Minimal",
|
||||
"time": {
|
||||
"created": 1700000000000,
|
||||
"updated": 1700000000000
|
||||
}
|
||||
}"#;
|
||||
|
||||
let session: Result<oc::Session, _> = serde_json::from_str(session_json);
|
||||
assert!(session.is_ok(), "Failed to parse minimal session: {:?}", session.err());
|
||||
|
||||
let session = session.unwrap();
|
||||
assert_eq!(session.id, "ses_minimal");
|
||||
assert!(session.parent_id.is_none());
|
||||
assert!(session.summary.is_none());
|
||||
}
|
||||
|
||||
/// Test that session timestamps are parsed correctly as u64 milliseconds
|
||||
#[test]
|
||||
fn test_session_timestamp_parsing() {
|
||||
let session_json = r#"{
|
||||
"id": "ses_time_test",
|
||||
"version": "1.0.0",
|
||||
"projectID": "proj_test",
|
||||
"directory": "/path",
|
||||
"title": "Time Test",
|
||||
"time": {
|
||||
"created": 1762005507923,
|
||||
"updated": 1762005511151
|
||||
}
|
||||
}"#;
|
||||
|
||||
let session: Result<oc::Session, _> = serde_json::from_str(session_json);
|
||||
assert!(session.is_ok());
|
||||
|
||||
let session = session.unwrap();
|
||||
// Verify timestamps are reasonable (year 2025-2026 range)
|
||||
assert!(session.time.created > 1700000000000);
|
||||
assert!(session.time.updated >= session.time.created);
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
/// Comprehensive edge case tests for SessionUpdate variants
|
||||
use dirigent_protocol::types::{
|
||||
ContentBlock, Meta, SessionUpdate, ToolCall, ToolCallContent, ToolCallStatus,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// ===== UserMessageChunk Tests =====
|
||||
|
||||
/// Test UserMessageChunk minimal (no _meta)
|
||||
#[test]
|
||||
fn test_user_message_chunk_minimal() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_001".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_001"#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk with empty message_id
|
||||
#[test]
|
||||
fn test_user_message_chunk_empty_message_id() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: String::new(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""message_id":"""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
SessionUpdate::UserMessageChunk { message_id, .. } => {
|
||||
assert_eq!(message_id, "");
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk with ResourceLink content
|
||||
#[test]
|
||||
fn test_user_message_chunk_with_resource_link() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_002".to_string(),
|
||||
content: ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file.txt".to_string(),
|
||||
name: Some("file.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(json.contains(r#""type":"resource_link"#)); // nested type
|
||||
assert!(json.contains(r#""uri":"file:///path/to/file.txt"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk with _meta
|
||||
#[test]
|
||||
fn test_user_message_chunk_with_meta() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_003".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
},
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta":{}"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== AgentMessageChunk Tests =====
|
||||
|
||||
/// Test AgentMessageChunk minimal
|
||||
#[test]
|
||||
fn test_agent_message_chunk_minimal() {
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_001".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Agent response".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_message_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_agent_001"#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test AgentMessageChunk with empty message_id
|
||||
#[test]
|
||||
fn test_agent_message_chunk_empty_message_id() {
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: String::new(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Response".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
SessionUpdate::AgentMessageChunk { message_id, .. } => {
|
||||
assert_eq!(message_id, "");
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test AgentMessageChunk with complex meta
|
||||
#[test]
|
||||
fn test_agent_message_chunk_with_complex_meta() {
|
||||
let mut extra = std::collections::HashMap::new();
|
||||
extra.insert("timestamp".to_string(), json!("2025-11-10T12:00:00Z"));
|
||||
extra.insert("duration_ms".to_string(), json!(123));
|
||||
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_002".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Response".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: None,
|
||||
extra,
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta""#));
|
||||
assert!(json.contains(r#""timestamp""#));
|
||||
assert!(json.contains(r#""duration_ms""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== AgentThoughtChunk Tests =====
|
||||
|
||||
/// Test AgentThoughtChunk minimal
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_minimal() {
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_001".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Thinking...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_thought_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_thought_001"#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test AgentThoughtChunk with empty text
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_empty_text() {
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_002".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: String::new(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""text":"""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test AgentThoughtChunk with very long text
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_long_text() {
|
||||
let long_text = "Analyzing the problem...\n".repeat(1000);
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_003".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: long_text.clone(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
SessionUpdate::AgentThoughtChunk { content, .. } => {
|
||||
if let ContentBlock::Text { text } = content {
|
||||
assert_eq!(text.len(), long_text.len());
|
||||
} else {
|
||||
panic!("Expected Text content");
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ToolCall Tests =====
|
||||
|
||||
/// Test ToolCall variant minimal
|
||||
#[test]
|
||||
fn test_tool_call_variant_minimal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_001".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_001".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
assert!(json.contains(r#""message_id":"msg_tool_001"#));
|
||||
assert!(json.contains(r#""tool_call""#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCall variant with complex nested ToolCall
|
||||
#[test]
|
||||
fn test_tool_call_variant_complex() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_002".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Line 1".to_string(),
|
||||
}),
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Line 2".to_string(),
|
||||
}),
|
||||
],
|
||||
raw_input: Some(json!({"path": "/tmp/test.txt"})),
|
||||
raw_output: Some(json!({"bytes": 1024})),
|
||||
title: Some("Read file".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 42})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_002".to_string(),
|
||||
tool_call,
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
assert!(json.contains(r#""tool_call""#));
|
||||
assert!(json.contains(r#""raw_input""#));
|
||||
assert!(json.contains(r#""raw_output""#));
|
||||
assert!(json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCall variant with Error status
|
||||
#[test]
|
||||
fn test_tool_call_variant_with_error() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_003".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"command": "invalid"})),
|
||||
raw_output: None,
|
||||
title: Some("Failed command".to_string()),
|
||||
error: Some("Command not found".to_string()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_003".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""status":"failed""#));
|
||||
assert!(json.contains(r#""error":"Command not found""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== ToolCallUpdate Tests =====
|
||||
|
||||
/// Test ToolCallUpdate variant minimal
|
||||
#[test]
|
||||
fn test_tool_call_update_minimal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_004".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_001".to_string(),
|
||||
tool_call_id: "call_004".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call_update"#));
|
||||
assert!(json.contains(r#""message_id":"msg_update_001"#));
|
||||
assert!(json.contains(r#""tool_call_id":"call_004"#));
|
||||
assert!(json.contains(r#""tool_call""#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCallUpdate with mismatched IDs
|
||||
#[test]
|
||||
fn test_tool_call_update_mismatched_ids() {
|
||||
// This is technically allowed by the type system, though semantically odd
|
||||
let tool_call = ToolCall {
|
||||
id: "call_005".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_002".to_string(),
|
||||
tool_call_id: "call_DIFFERENT".to_string(), // Different from tool_call.id
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""tool_call_id":"call_DIFFERENT"#));
|
||||
assert!(json.contains(r#""id":"call_005"#)); // nested id
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCallUpdate with completed status
|
||||
#[test]
|
||||
fn test_tool_call_update_completed() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_006".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "File contents".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"path": "/tmp/file"})),
|
||||
raw_output: Some(json!({"success": true})),
|
||||
title: Some("Read operation".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"lines": 42})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_003".to_string(),
|
||||
tool_call_id: "call_006".to_string(),
|
||||
tool_call,
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""status":"completed""#));
|
||||
assert!(json.contains(r#""raw_output""#));
|
||||
assert!(json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== Type Tag Tests =====
|
||||
|
||||
/// Test all variants have correct snake_case type tags
|
||||
#[test]
|
||||
fn test_all_type_tags_snake_case() {
|
||||
let user_chunk = SessionUpdate::UserMessageChunk {
|
||||
message_id: "m1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&user_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(!json.contains(r#""type":"UserMessageChunk"#));
|
||||
|
||||
let agent_chunk = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "m2".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&agent_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_message_chunk"#));
|
||||
|
||||
let thought_chunk = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "m3".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&thought_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_thought_chunk"#));
|
||||
|
||||
let tool_call = SessionUpdate::ToolCall {
|
||||
message_id: "m4".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c1".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
assert!(!json.contains(r#""type":"ToolCall"#));
|
||||
|
||||
let tool_call_update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "m5".to_string(),
|
||||
tool_call_id: "c2".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c2".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tool_call_update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call_update"#));
|
||||
}
|
||||
|
||||
// ===== Deserialization Error Cases =====
|
||||
|
||||
/// Test missing type field
|
||||
#[test]
|
||||
fn test_missing_type_field() {
|
||||
let json = r#"{
|
||||
"message_id": "msg_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without type field");
|
||||
}
|
||||
|
||||
/// Test invalid type value
|
||||
#[test]
|
||||
fn test_invalid_type_value() {
|
||||
let json = r#"{
|
||||
"type": "invalid_message_chunk",
|
||||
"message_id": "msg_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with invalid type");
|
||||
}
|
||||
|
||||
/// Test missing message_id
|
||||
#[test]
|
||||
fn test_missing_message_id() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without message_id");
|
||||
}
|
||||
|
||||
/// Test missing content field
|
||||
#[test]
|
||||
fn test_missing_content_field() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_001"
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without content");
|
||||
}
|
||||
|
||||
/// Test missing tool_call field
|
||||
#[test]
|
||||
fn test_missing_tool_call_field() {
|
||||
let json = r#"{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_001"
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without tool_call");
|
||||
}
|
||||
|
||||
/// Test missing tool_call_id in ToolCallUpdate
|
||||
#[test]
|
||||
fn test_missing_tool_call_id() {
|
||||
let json = r#"{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_001",
|
||||
"tool_call": {
|
||||
"id": "call_001",
|
||||
"tool_name": "test",
|
||||
"status": "running"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without tool_call_id");
|
||||
}
|
||||
|
||||
/// Test null values for required fields
|
||||
#[test]
|
||||
fn test_null_required_values() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": null,
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null message_id");
|
||||
}
|
||||
|
||||
/// Test null _meta (should deserialize as None)
|
||||
#[test]
|
||||
fn test_null_meta() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
},
|
||||
"_meta": null
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { _meta, .. } => {
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Roundtrip Tests =====
|
||||
|
||||
/// Test roundtrip for all variants
|
||||
#[test]
|
||||
fn test_all_variants_roundtrip() {
|
||||
let variants = vec![
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "User message".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_2".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Agent response".to_string(),
|
||||
},
|
||||
_meta: Some(Meta::default()),
|
||||
},
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_3".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Thinking...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
SessionUpdate::ToolCall {
|
||||
message_id: "msg_4".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"cmd": "ls"})),
|
||||
raw_output: None,
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_5".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "file1.txt\nfile2.txt".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"cmd": "ls"})),
|
||||
raw_output: Some(json!({"exit_code": 0})),
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 100})),
|
||||
origin: None,
|
||||
},
|
||||
_meta: Some(Meta::default()),
|
||||
},
|
||||
];
|
||||
|
||||
for variant in variants {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Complex Content =====
|
||||
|
||||
/// Test UserMessageChunk with complex nested content
|
||||
#[test]
|
||||
fn test_complex_nested_content() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_complex".to_string(),
|
||||
content: ContentBlock::ResourceLink {
|
||||
uri: "data:text/plain;base64,SGVsbG8gV29ybGQh".to_string(),
|
||||
name: Some("embedded.txt".to_string()),
|
||||
mime_type: Some("text/plain; charset=utf-8".to_string()),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== Clone and Debug =====
|
||||
|
||||
/// Test SessionUpdate clone
|
||||
#[test]
|
||||
fn test_session_update_clone() {
|
||||
let original = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_clone".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
/// Test SessionUpdate debug formatting
|
||||
#[test]
|
||||
fn test_session_update_debug() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_debug".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Debug test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let debug_str = format!("{:?}", update);
|
||||
assert!(debug_str.contains("UserMessageChunk"));
|
||||
assert!(debug_str.contains("msg_debug"));
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Empty Collections =====
|
||||
|
||||
/// Test ToolCall with empty content array persists correctly
|
||||
#[test]
|
||||
fn test_tool_call_empty_content_persists() {
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_empty".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_empty".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![], // Explicitly empty
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""content":[]"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
SessionUpdate::ToolCall { tool_call, .. } => {
|
||||
assert_eq!(tool_call.content.len(), 0);
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
/// Comprehensive edge case tests for ToolCall and ToolCallStatus
|
||||
use dirigent_protocol::types::{ContentBlock, ToolCall, ToolCallContent, ToolCallStatus};
|
||||
use serde_json::json;
|
||||
|
||||
// ===== ToolCallStatus Tests =====
|
||||
|
||||
/// Test all ToolCallStatus variants serialize correctly
|
||||
#[test]
|
||||
fn test_all_status_variants_serialize() {
|
||||
let pending = ToolCallStatus::Pending;
|
||||
assert_eq!(serde_json::to_string(&pending).unwrap(), r#""pending""#);
|
||||
|
||||
let running = ToolCallStatus::Running;
|
||||
assert_eq!(serde_json::to_string(&running).unwrap(), r#""running""#);
|
||||
|
||||
let completed = ToolCallStatus::Completed;
|
||||
assert_eq!(
|
||||
serde_json::to_string(&completed).unwrap(),
|
||||
r#""completed""#
|
||||
);
|
||||
|
||||
let error = ToolCallStatus::Error;
|
||||
assert_eq!(serde_json::to_string(&error).unwrap(), r#""error""#);
|
||||
}
|
||||
|
||||
/// Test ToolCallStatus deserialization
|
||||
#[test]
|
||||
fn test_status_deserialization() {
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""pending""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Pending);
|
||||
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""running""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Running);
|
||||
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""completed""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Completed);
|
||||
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""error""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Error);
|
||||
}
|
||||
|
||||
/// Test invalid status deserialization
|
||||
#[test]
|
||||
fn test_invalid_status_deserialization() {
|
||||
let result: Result<ToolCallStatus, _> = serde_json::from_str(r#""invalid""#);
|
||||
assert!(result.is_err(), "Should fail with invalid status");
|
||||
|
||||
let result: Result<ToolCallStatus, _> = serde_json::from_str(r#""PENDING""#);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should fail with uppercase (not snake_case)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test ToolCallStatus roundtrip
|
||||
#[test]
|
||||
fn test_status_roundtrip() {
|
||||
let statuses = [
|
||||
ToolCallStatus::Pending,
|
||||
ToolCallStatus::Running,
|
||||
ToolCallStatus::Completed,
|
||||
ToolCallStatus::Error,
|
||||
];
|
||||
|
||||
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 ToolCallStatus equality and copy
|
||||
#[test]
|
||||
fn test_status_equality_and_copy() {
|
||||
let status1 = ToolCallStatus::Pending;
|
||||
let status2 = status1; // Copy
|
||||
assert_eq!(status1, status2);
|
||||
|
||||
let status3 = ToolCallStatus::Running;
|
||||
assert_ne!(status1, status3);
|
||||
}
|
||||
|
||||
// ===== ToolCall Minimal Tests =====
|
||||
|
||||
/// Test ToolCall with minimal required fields
|
||||
#[test]
|
||||
fn test_tool_call_minimal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_min".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
|
||||
// Required fields present
|
||||
assert!(json.contains(r#""id":"call_min""#));
|
||||
assert!(json.contains(r#""tool_name":"test""#));
|
||||
assert!(json.contains(r#""status":"pending""#));
|
||||
assert!(json.contains(r#""content":[]"#));
|
||||
|
||||
// Optional fields not present
|
||||
assert!(!json.contains(r#""raw_input""#));
|
||||
assert!(!json.contains(r#""raw_output""#));
|
||||
assert!(!json.contains(r#""title""#));
|
||||
assert!(!json.contains(r#""error""#));
|
||||
assert!(!json.contains(r#""metadata""#));
|
||||
}
|
||||
|
||||
/// Test ToolCall with all fields populated
|
||||
#[test]
|
||||
fn test_tool_call_maximal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_max".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Output".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"command": "ls"})),
|
||||
raw_output: Some(json!({"exit_code": 0})),
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 123})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
|
||||
// All fields present
|
||||
assert!(json.contains(r#""id":"call_max""#));
|
||||
assert!(json.contains(r#""tool_name":"bash""#));
|
||||
assert!(json.contains(r#""status":"completed""#));
|
||||
assert!(json.contains(r#""content""#));
|
||||
assert!(json.contains(r#""raw_input""#));
|
||||
assert!(json.contains(r#""raw_output""#));
|
||||
assert!(json.contains(r#""title":"List files""#));
|
||||
assert!(json.contains(r#""metadata""#));
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Empty Strings =====
|
||||
|
||||
/// Test empty tool_name
|
||||
#[test]
|
||||
fn test_empty_tool_name() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_empty_name".to_string(),
|
||||
tool_name: String::new(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""tool_name":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.tool_name, "");
|
||||
}
|
||||
|
||||
/// Test empty id
|
||||
#[test]
|
||||
fn test_empty_id() {
|
||||
let tool_call = ToolCall {
|
||||
id: String::new(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""id":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.id, "");
|
||||
}
|
||||
|
||||
/// Test empty title (Some(""))
|
||||
#[test]
|
||||
fn test_empty_title() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_empty_title".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: Some(String::new()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""title":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.title, Some(String::new()));
|
||||
}
|
||||
|
||||
/// Test empty error message (Some(""))
|
||||
#[test]
|
||||
fn test_empty_error_message() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_empty_error".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some(String::new()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""error":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.error, Some(String::new()));
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Large Data =====
|
||||
|
||||
/// Test very long error message
|
||||
#[test]
|
||||
fn test_long_error_message() {
|
||||
let long_error = "Error: ".to_string() + &"x".repeat(10_000);
|
||||
let tool_call = ToolCall {
|
||||
id: "call_long_error".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some(long_error.clone()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.error.unwrap().len(), long_error.len());
|
||||
}
|
||||
|
||||
/// Test large metadata
|
||||
#[test]
|
||||
fn test_large_metadata() {
|
||||
let large_meta = json!({
|
||||
"key1": "value".repeat(1000),
|
||||
"key2": [1, 2, 3, 4, 5],
|
||||
"nested": {
|
||||
"deep": {
|
||||
"value": "test"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: "call_large_meta".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: Some(large_meta.clone()),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.metadata, Some(large_meta));
|
||||
}
|
||||
|
||||
/// Test many content blocks
|
||||
#[test]
|
||||
fn test_many_content_blocks() {
|
||||
let mut content = vec![];
|
||||
for i in 0..100 {
|
||||
content.push(ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: format!("Line {}", i),
|
||||
}));
|
||||
}
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: "call_many_blocks".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: content.clone(),
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.content.len(), 100);
|
||||
assert_eq!(deserialized.content, content);
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Special Characters =====
|
||||
|
||||
/// Test special characters in tool_name
|
||||
#[test]
|
||||
fn test_special_chars_in_tool_name() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_special".to_string(),
|
||||
tool_name: "bash::execute!@#$%^&*()".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.tool_name, "bash::execute!@#$%^&*()");
|
||||
}
|
||||
|
||||
/// Test unicode in error message
|
||||
#[test]
|
||||
fn test_unicode_in_error() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_unicode".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some("错误: 文件不存在 🚫".to_string()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.error.unwrap(), "错误: 文件不存在 🚫");
|
||||
}
|
||||
|
||||
// ===== Default Content Field =====
|
||||
|
||||
/// Test that content defaults to empty vec when not in JSON
|
||||
#[test]
|
||||
fn test_content_default() {
|
||||
let json = r#"{
|
||||
"id": "call_default",
|
||||
"tool_name": "test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let tool_call: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(tool_call.content, vec![]);
|
||||
}
|
||||
|
||||
/// Test that explicit empty content works
|
||||
#[test]
|
||||
fn test_explicit_empty_content() {
|
||||
let json = r#"{
|
||||
"id": "call_explicit",
|
||||
"tool_name": "test",
|
||||
"status": "pending",
|
||||
"content": []
|
||||
}"#;
|
||||
|
||||
let tool_call: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(tool_call.content, vec![]);
|
||||
}
|
||||
|
||||
// ===== Error Cases =====
|
||||
|
||||
/// Test missing required field (id)
|
||||
#[test]
|
||||
fn test_missing_id() {
|
||||
let json = r#"{
|
||||
"tool_name": "test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without id");
|
||||
}
|
||||
|
||||
/// Test missing required field (tool_name)
|
||||
#[test]
|
||||
fn test_missing_tool_name() {
|
||||
let json = r#"{
|
||||
"id": "call_test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without tool_name");
|
||||
}
|
||||
|
||||
/// Test missing required field (status)
|
||||
#[test]
|
||||
fn test_missing_status() {
|
||||
let json = r#"{
|
||||
"id": "call_test",
|
||||
"tool_name": "test"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without status");
|
||||
}
|
||||
|
||||
/// Test null values for required fields
|
||||
#[test]
|
||||
fn test_null_required_fields() {
|
||||
let json = r#"{
|
||||
"id": null,
|
||||
"tool_name": "test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null id");
|
||||
}
|
||||
|
||||
/// Test null values for optional fields (should be None)
|
||||
#[test]
|
||||
fn test_null_optional_fields() {
|
||||
let json = r#"{
|
||||
"id": "call_null_opts",
|
||||
"tool_name": "test",
|
||||
"status": "pending",
|
||||
"raw_input": null,
|
||||
"raw_output": null,
|
||||
"title": null,
|
||||
"error": null,
|
||||
"metadata": null
|
||||
}"#;
|
||||
|
||||
let tool_call: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert!(tool_call.raw_input.is_none());
|
||||
assert!(tool_call.raw_output.is_none());
|
||||
assert!(tool_call.title.is_none());
|
||||
assert!(tool_call.error.is_none());
|
||||
assert!(tool_call.metadata.is_none());
|
||||
}
|
||||
|
||||
// ===== Status-Specific Tests =====
|
||||
|
||||
/// Test Error status with error message
|
||||
#[test]
|
||||
fn test_error_status_with_message() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_error".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some("Something went wrong".to_string()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""status":"error""#));
|
||||
assert!(json.contains(r#""error":"Something went wrong""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.status, ToolCallStatus::Error);
|
||||
assert_eq!(
|
||||
deserialized.error,
|
||||
Some("Something went wrong".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Test Completed status with output
|
||||
#[test]
|
||||
fn test_completed_status_with_output() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_completed".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "File contents".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"path": "/tmp/test.txt"})),
|
||||
raw_output: Some(json!({"bytes_read": 1024})),
|
||||
title: Some("Read file".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 42})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.status, ToolCallStatus::Completed);
|
||||
assert!(deserialized.raw_output.is_some());
|
||||
assert!(deserialized.error.is_none());
|
||||
}
|
||||
|
||||
// ===== Roundtrip Tests =====
|
||||
|
||||
/// Test roundtrip for all status variants
|
||||
#[test]
|
||||
fn test_roundtrip_all_statuses() {
|
||||
let statuses = [
|
||||
ToolCallStatus::Pending,
|
||||
ToolCallStatus::Running,
|
||||
ToolCallStatus::Completed,
|
||||
ToolCallStatus::Error,
|
||||
];
|
||||
|
||||
for status in statuses {
|
||||
let tool_call = ToolCall {
|
||||
id: format!("call_{:?}", status),
|
||||
tool_name: "test".to_string(),
|
||||
status,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(tool_call, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Clone and Debug =====
|
||||
|
||||
/// Test ToolCall clone
|
||||
#[test]
|
||||
fn test_tool_call_clone() {
|
||||
let original = ToolCall {
|
||||
id: "call_clone".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
/// Test ToolCall debug formatting
|
||||
#[test]
|
||||
fn test_tool_call_debug() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_debug".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let debug_str = format!("{:?}", tool_call);
|
||||
assert!(debug_str.contains("ToolCall"));
|
||||
assert!(debug_str.contains("call_debug"));
|
||||
}
|
||||
Reference in New Issue
Block a user