sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
+230
View File
@@ -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"));
}