sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
# Dirigent Protocol Tests
|
||||
|
||||
This directory contains comprehensive tests for the Dirigent protocol and OpenCode adapter.
|
||||
|
||||
## Test Files
|
||||
|
||||
### `protocol_tests.rs`
|
||||
Core protocol translation tests that verify OpenCode events are correctly translated to Dirigent protocol events.
|
||||
|
||||
**Coverage:**
|
||||
- Session creation and updates
|
||||
- User and assistant messages
|
||||
- Message parts (text, thinking, tool)
|
||||
- Event stream parsing
|
||||
- Protocol serialization/deserialization
|
||||
|
||||
**Run:** `cargo test --test protocol_tests`
|
||||
|
||||
### `deduplication_tests.rs`
|
||||
Tests for the stateful adapter's deduplication logic, ensuring no duplicate messages or parts appear in the UI.
|
||||
|
||||
**Coverage:**
|
||||
- Duplicate `MessageStarted` filtering
|
||||
- Duplicate `MessageCompleted` filtering
|
||||
- Part completion signal (`delta: null`) filtering
|
||||
- Different part types not being filtered
|
||||
- Streaming part updates working correctly
|
||||
- Full tit-tat conversation flow
|
||||
- Adapter state independence
|
||||
|
||||
**Run:** `cargo test --test deduplication_tests`
|
||||
|
||||
### `session_list_tests.rs`
|
||||
Tests for parsing OpenCode session list responses.
|
||||
|
||||
**Coverage:**
|
||||
- Session list array parsing
|
||||
- Empty session list handling
|
||||
- Single session deserialization
|
||||
- Optional fields handling
|
||||
- Timestamp parsing validation
|
||||
|
||||
**Run:** `cargo test --test session_list_tests`
|
||||
|
||||
## Fixtures
|
||||
|
||||
### `fixtures/sample_events.jsonl`
|
||||
Sample OpenCode SSE events in JSONL format (one event per line). Used for parsing validation and event stream testing.
|
||||
|
||||
### `fixtures/opencode_session_response.json`
|
||||
Real OpenCode session list response. Used for session deserialization tests.
|
||||
|
||||
**Source:** Copied from `/docs/building/opencode_session_response.json`
|
||||
|
||||
## Running All Tests
|
||||
|
||||
```bash
|
||||
# Run all protocol tests
|
||||
cargo test --package dirigent_protocol
|
||||
|
||||
# Run with output
|
||||
cargo test --package dirigent_protocol -- --nocapture
|
||||
|
||||
# Run specific test
|
||||
cargo test --package dirigent_protocol test_tit_tat_flow
|
||||
|
||||
# Run tests matching pattern
|
||||
cargo test --package dirigent_protocol duplicate
|
||||
```
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
### For OpenCode Event Translation
|
||||
|
||||
Add to `protocol_tests.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_translate_new_feature() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Create OpenCode event
|
||||
let oc_event = oc::Event::YourEvent { ... };
|
||||
|
||||
// Translate
|
||||
let result = adapter.translate_event(oc_event);
|
||||
|
||||
// Assert
|
||||
assert!(result.is_ok());
|
||||
match result.unwrap() {
|
||||
Event::YourDirigentEvent(data) => {
|
||||
assert_eq!(data.field, expected_value);
|
||||
}
|
||||
_ => panic!("Expected YourDirigentEvent"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### For Deduplication Logic
|
||||
|
||||
Add to `deduplication_tests.rs`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_new_deduplication_rule() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Send first event
|
||||
let result1 = adapter.translate_event(first_event);
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// Send duplicate event
|
||||
let result2 = adapter.translate_event(duplicate_event);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
```
|
||||
|
||||
### For New Fixtures
|
||||
|
||||
1. Place fixture file in `tests/fixtures/`
|
||||
2. Use `include_str!` to load it:
|
||||
```rust
|
||||
let fixture = include_str!("fixtures/your_file.json");
|
||||
```
|
||||
|
||||
## Test Principles
|
||||
|
||||
### Stateful Adapter Pattern
|
||||
|
||||
⚠️ **IMPORTANT:** The adapter maintains state, so:
|
||||
|
||||
```rust
|
||||
// ✅ CORRECT: One adapter for entire event stream
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
for event in events {
|
||||
adapter.translate_event(event);
|
||||
}
|
||||
|
||||
// ❌ WRONG: New adapter each time (loses state!)
|
||||
for event in events {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
adapter.translate_event(event);
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Duplicates
|
||||
|
||||
When testing deduplication:
|
||||
1. Send the first event → should succeed
|
||||
2. Send the duplicate event → should fail with `TranslationError::Duplicate`
|
||||
3. Always use the SAME adapter instance
|
||||
|
||||
### Real-World Fixtures
|
||||
|
||||
Fixtures should come from actual OpenCode API responses when possible:
|
||||
- Captures real-world edge cases
|
||||
- Ensures compatibility with API changes
|
||||
- Documents actual behavior
|
||||
|
||||
## CI Integration
|
||||
|
||||
These tests run automatically on:
|
||||
- Every commit (via `cargo test`)
|
||||
- Pull requests
|
||||
- Before releases
|
||||
|
||||
**Status:** All tests should pass before merging.
|
||||
|
||||
## Coverage Report
|
||||
|
||||
```bash
|
||||
# Install tarpaulin for coverage
|
||||
cargo install cargo-tarpaulin
|
||||
|
||||
# Generate coverage report
|
||||
cargo tarpaulin --package dirigent_protocol --out Html
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SSE Deduplication](../../../docs/building/sse_deduplication.md) - How deduplication works
|
||||
- [SSE Event Flow Analysis](../../../docs/building/sse_event_flow_analysis.md) - OpenCode event patterns
|
||||
- [Protocol Abstraction Plan](../../../docs/building/protocol_abstraction_plan.md) - Adapter architecture
|
||||
|
||||
## Test Statistics
|
||||
|
||||
**Last Updated:** 2025-11-01
|
||||
|
||||
- **Total Tests:** 24
|
||||
- **Deduplication Tests:** 7
|
||||
- **Session Tests:** 5
|
||||
- **Protocol Tests:** 12
|
||||
- **All Passing:** ✅
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Test Fails with "argument #1 of type &OpenCodeAdapter is missing"
|
||||
|
||||
**Problem:** You're calling `OpenCodeAdapter::translate_event(event)` as a static method.
|
||||
|
||||
**Solution:** Create an adapter instance first:
|
||||
```rust
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(event);
|
||||
```
|
||||
|
||||
### Test Fails with "pattern does not mention field part_id"
|
||||
|
||||
**Problem:** The `MessagePartAdded` event now includes a `part_id` field.
|
||||
|
||||
**Solution:** Update your pattern match:
|
||||
```rust
|
||||
// Before
|
||||
Event::MessagePartAdded { message_id, part, delta } => { ... }
|
||||
|
||||
// After
|
||||
Event::MessagePartAdded { message_id, part_id: _, part, delta } => { ... }
|
||||
```
|
||||
|
||||
### Deduplication Test Unexpectedly Passes
|
||||
|
||||
**Problem:** You're creating a new adapter for each event.
|
||||
|
||||
**Solution:** Create ONE adapter and reuse it:
|
||||
```rust
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
adapter.translate_event(event1); // First time
|
||||
adapter.translate_event(event1); // Should be duplicate!
|
||||
```
|
||||
@@ -0,0 +1,378 @@
|
||||
/// Comprehensive edge case tests for ContentBlock
|
||||
use dirigent_protocol::types::ContentBlock;
|
||||
|
||||
/// Test empty string in Text variant
|
||||
#[test]
|
||||
fn test_text_empty_string() {
|
||||
let block = ContentBlock::Text {
|
||||
text: String::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains(r#""type":"text"#));
|
||||
assert!(json.contains(r#""text":"""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test very long string (>10KB) in Text variant
|
||||
#[test]
|
||||
fn test_text_very_long_string() {
|
||||
let long_text = "a".repeat(10_000);
|
||||
let block = ContentBlock::Text {
|
||||
text: long_text.clone(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::Text { text } => {
|
||||
assert_eq!(text.len(), 10_000);
|
||||
assert_eq!(text, long_text);
|
||||
}
|
||||
_ => panic!("Expected Text variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test special characters (unicode, emojis, newlines) in Text
|
||||
#[test]
|
||||
fn test_text_special_characters() {
|
||||
let special_text = "Hello 👋\nWorld 🌍\t中文\r\n\"quotes\" and 'apostrophes' \\ backslash";
|
||||
let block = ContentBlock::Text {
|
||||
text: special_text.to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::Text { text } => {
|
||||
assert_eq!(text, special_text);
|
||||
}
|
||||
_ => panic!("Expected Text variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test JSON control characters in Text
|
||||
#[test]
|
||||
fn test_text_json_control_characters() {
|
||||
let control_chars = "Line1\nLine2\r\nLine3\tTabbed\x08Backspace";
|
||||
let block = ContentBlock::Text {
|
||||
text: control_chars.to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::Text { text } => {
|
||||
assert_eq!(text, control_chars);
|
||||
}
|
||||
_ => panic!("Expected Text variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ResourceLink with empty URI
|
||||
#[test]
|
||||
fn test_resource_link_empty_uri() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: String::new(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
assert!(json.contains(r#""type":"resource_link"#));
|
||||
assert!(json.contains(r#""uri":"""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with all optional fields as None
|
||||
#[test]
|
||||
fn test_resource_link_all_none() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
// Verify optional fields are NOT in JSON
|
||||
assert!(!json.contains(r#""name""#));
|
||||
assert!(!json.contains(r#""mime_type""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with all optional fields as Some
|
||||
#[test]
|
||||
fn test_resource_link_all_some() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "https://example.com/file.pdf".to_string(),
|
||||
name: Some("document.pdf".to_string()),
|
||||
mime_type: Some("application/pdf".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
// Verify all fields ARE in JSON
|
||||
assert!(json.contains(r#""uri":"https://example.com/file.pdf"#));
|
||||
assert!(json.contains(r#""name":"document.pdf"#));
|
||||
assert!(json.contains(r#""mime_type":"application/pdf"#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with only name
|
||||
#[test]
|
||||
fn test_resource_link_only_name() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file".to_string(),
|
||||
name: Some("my_file".to_string()),
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
assert!(json.contains(r#""name":"my_file"#));
|
||||
assert!(!json.contains(r#""mime_type""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with only mime_type
|
||||
#[test]
|
||||
fn test_resource_link_only_mime_type() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file".to_string(),
|
||||
name: None,
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
assert!(!json.contains(r#""name""#));
|
||||
assert!(json.contains(r#""mime_type":"text/plain"#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with empty optional strings
|
||||
#[test]
|
||||
fn test_resource_link_empty_optional_strings() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///test".to_string(),
|
||||
name: Some(String::new()),
|
||||
mime_type: Some(String::new()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
|
||||
// Empty strings should still serialize
|
||||
assert!(json.contains(r#""name":"""#));
|
||||
assert!(json.contains(r#""mime_type":"""#));
|
||||
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test ResourceLink with special characters in URI
|
||||
#[test]
|
||||
fn test_resource_link_special_uri() {
|
||||
let uri = "file:///path/to/file%20with%20spaces.txt?query=param&other=value#fragment";
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: uri.to_string(),
|
||||
name: Some("file with spaces.txt".to_string()),
|
||||
mime_type: Some("text/plain; charset=utf-8".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::ResourceLink {
|
||||
uri: deser_uri,
|
||||
name,
|
||||
mime_type,
|
||||
} => {
|
||||
assert_eq!(deser_uri, uri);
|
||||
assert_eq!(name, Some("file with spaces.txt".to_string()));
|
||||
assert_eq!(mime_type, Some("text/plain; charset=utf-8".to_string()));
|
||||
}
|
||||
_ => panic!("Expected ResourceLink variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ResourceLink with very long URI
|
||||
#[test]
|
||||
fn test_resource_link_long_uri() {
|
||||
let long_path = format!("file:///{}", "a/".repeat(500));
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: long_path.clone(),
|
||||
name: Some("file.txt".to_string()),
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&block).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
ContentBlock::ResourceLink { uri, .. } => {
|
||||
assert_eq!(uri, long_path);
|
||||
}
|
||||
_ => panic!("Expected ResourceLink variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test deserialization from JSON without type field (should fail)
|
||||
#[test]
|
||||
fn test_deserialization_missing_type_field() {
|
||||
let json = r#"{"text": "Hello"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without type field");
|
||||
}
|
||||
|
||||
/// Test deserialization with invalid type value
|
||||
#[test]
|
||||
fn test_deserialization_invalid_type() {
|
||||
let json = r#"{"type": "invalid_type", "text": "Hello"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with invalid type");
|
||||
}
|
||||
|
||||
/// Test deserialization Text without text field (should fail)
|
||||
#[test]
|
||||
fn test_deserialization_text_missing_field() {
|
||||
let json = r#"{"type": "text"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without text field");
|
||||
}
|
||||
|
||||
/// Test deserialization ResourceLink without uri field (should fail)
|
||||
#[test]
|
||||
fn test_deserialization_resource_link_missing_uri() {
|
||||
let json = r#"{"type": "resource_link", "name": "file.txt"}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without uri field");
|
||||
}
|
||||
|
||||
/// Test that type tag is snake_case
|
||||
#[test]
|
||||
fn test_type_tag_format() {
|
||||
let text = ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&text).unwrap();
|
||||
assert!(json.contains(r#""type":"text"#));
|
||||
assert!(!json.contains(r#""type":"Text"#));
|
||||
|
||||
let resource = ContentBlock::ResourceLink {
|
||||
uri: "file:///test".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
let json = serde_json::to_string(&resource).unwrap();
|
||||
assert!(json.contains(r#""type":"resource_link"#));
|
||||
assert!(!json.contains(r#""type":"ResourceLink"#));
|
||||
}
|
||||
|
||||
/// Test roundtrip with all ContentBlock variants
|
||||
#[test]
|
||||
fn test_all_variants_roundtrip() {
|
||||
let variants = vec![
|
||||
ContentBlock::Text {
|
||||
text: "Test text".to_string(),
|
||||
},
|
||||
ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: Some("test.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
},
|
||||
ContentBlock::ResourceLink {
|
||||
uri: "https://example.com/resource".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
},
|
||||
];
|
||||
|
||||
for variant in variants {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test pretty-printed JSON
|
||||
#[test]
|
||||
fn test_pretty_json() {
|
||||
let block = ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: Some("test.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&block).unwrap();
|
||||
|
||||
// Should be parseable
|
||||
let deserialized: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(block, deserialized);
|
||||
}
|
||||
|
||||
/// Test null values in JSON (should fail for required fields)
|
||||
#[test]
|
||||
fn test_null_values() {
|
||||
let json = r#"{"type": "text", "text": null}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null text");
|
||||
|
||||
let json = r#"{"type": "resource_link", "uri": null}"#;
|
||||
let result: Result<ContentBlock, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null uri");
|
||||
}
|
||||
|
||||
/// Test null values for optional fields (should deserialize as None)
|
||||
#[test]
|
||||
fn test_null_optional_fields() {
|
||||
let json = r#"{
|
||||
"type": "resource_link",
|
||||
"uri": "file:///test",
|
||||
"name": null,
|
||||
"mime_type": null
|
||||
}"#;
|
||||
let result: ContentBlock = serde_json::from_str(json).unwrap();
|
||||
|
||||
match result {
|
||||
ContentBlock::ResourceLink {
|
||||
uri,
|
||||
name,
|
||||
mime_type,
|
||||
} => {
|
||||
assert_eq!(uri, "file:///test");
|
||||
assert!(name.is_none());
|
||||
assert!(mime_type.is_none());
|
||||
}
|
||||
_ => panic!("Expected ResourceLink"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ContentBlock Clone and PartialEq
|
||||
#[test]
|
||||
fn test_clone_and_equality() {
|
||||
let original = ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
};
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
|
||||
let different = ContentBlock::Text {
|
||||
text: "Different".to_string(),
|
||||
};
|
||||
assert_ne!(original, different);
|
||||
}
|
||||
|
||||
/// Test ContentBlock Debug formatting
|
||||
#[test]
|
||||
fn test_debug_formatting() {
|
||||
let block = ContentBlock::Text {
|
||||
text: "Debug test".to_string(),
|
||||
};
|
||||
let debug_str = format!("{:?}", block);
|
||||
assert!(debug_str.contains("Text"));
|
||||
assert!(debug_str.contains("Debug test"));
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
use dirigent_protocol::adapters::{OpenCodeAdapter, TranslationError};
|
||||
use dirigent_protocol::Event;
|
||||
use opencode_client::types as oc;
|
||||
|
||||
/// Test that duplicate MessageStarted events are filtered
|
||||
#[test]
|
||||
fn test_duplicate_message_started_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// First message.updated event (streaming)
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_test1".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000000000,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
// First event should succeed
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::MessageStarted { .. }));
|
||||
|
||||
// Second identical event should be filtered as duplicate
|
||||
let event2 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
|
||||
/// Test that duplicate MessageCompleted events are filtered
|
||||
#[test]
|
||||
fn test_duplicate_message_completed_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Completed message
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_test2".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000000000,
|
||||
completed: Some(1700000005000),
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
// First completed event should succeed
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::MessageCompleted { .. }));
|
||||
|
||||
// Second identical completed event should be filtered
|
||||
let event2 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
|
||||
/// Test that part updates with delta: None are filtered after first occurrence
|
||||
#[test]
|
||||
fn test_duplicate_part_completion_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// First part update with delta (streaming)
|
||||
let part = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_test1".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Hello world".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: part.clone(),
|
||||
delta: Some("Hello".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
// First event with delta should succeed
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::SessionUpdate { .. }));
|
||||
|
||||
// Second event for same part without delta (completion signal) should be filtered
|
||||
let event2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_err());
|
||||
assert!(matches!(result2.unwrap_err(), TranslationError::Duplicate));
|
||||
}
|
||||
|
||||
/// Test that different parts with same message are NOT filtered
|
||||
#[test]
|
||||
fn test_different_parts_not_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// Reasoning part
|
||||
let reasoning_part = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Let me think...".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part,
|
||||
delta: Some("Let me".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1.unwrap(), Event::SessionUpdate { .. }));
|
||||
|
||||
// Text part (different part, same message)
|
||||
let text_part = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Answer".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: text_part,
|
||||
delta: Some("Answer".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
// Different part should not be filtered
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_ok());
|
||||
assert!(matches!(result2.unwrap(), Event::SessionUpdate { .. }));
|
||||
}
|
||||
|
||||
/// Test streaming part updates are not filtered (same part_id, different delta)
|
||||
#[test]
|
||||
fn test_streaming_part_updates_not_filtered() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// First update
|
||||
let part1 = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_streaming".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Hello".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: part1,
|
||||
delta: Some("Hello".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result1 = adapter.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// Second update with more text
|
||||
let part2 = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_streaming".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_test".to_string(),
|
||||
text: "Hello world".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let event2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: part2,
|
||||
delta: Some(" world".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
// Second update with delta should NOT be filtered (streaming update)
|
||||
let result2 = adapter.translate_event(event2);
|
||||
assert!(result2.is_ok());
|
||||
assert!(matches!(result2.unwrap(), Event::SessionUpdate { .. }));
|
||||
}
|
||||
|
||||
/// Test full tit-tat flow with proper deduplication
|
||||
#[test]
|
||||
fn test_tit_tat_flow() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
|
||||
// 1. User message arrives (completed)
|
||||
let user_msg = oc::Message::User(oc::UserMessage {
|
||||
id: "msg_user".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::MessageTime {
|
||||
created: 1700000000000,
|
||||
},
|
||||
summary: None,
|
||||
});
|
||||
|
||||
let user_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: user_msg },
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(user_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::MessageCompleted { .. }));
|
||||
|
||||
// 2. Assistant message starts streaming
|
||||
let asst_msg_streaming = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000001000,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user".to_string()),
|
||||
model_id: Some("grok-code".to_string()),
|
||||
provider_id: Some("opencode".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let asst_start_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: asst_msg_streaming,
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(asst_start_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::MessageStarted { .. }));
|
||||
|
||||
// 3. Reasoning part streams
|
||||
let reasoning_part_1 = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "First".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let reasoning_event_1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part_1,
|
||||
delta: Some("First".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(reasoning_event_1);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// More reasoning updates...
|
||||
let reasoning_part_2 = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "First, the user is saying \"tit?\"".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let reasoning_event_2 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part_2,
|
||||
delta: Some(", the user is saying \"tit?\"".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(reasoning_event_2);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// 4. Reasoning completes (delta: None) - should be filtered
|
||||
let reasoning_part_complete = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "First, the user is saying \"tit?\" which seems like they're continuing the game.".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let reasoning_complete_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: reasoning_part_complete,
|
||||
delta: None, // Completion signal
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(reasoning_complete_event);
|
||||
assert!(result.is_err()); // Should be filtered as duplicate
|
||||
assert!(matches!(result.unwrap_err(), TranslationError::Duplicate));
|
||||
|
||||
// 5. Text part starts streaming
|
||||
let text_part_1 = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "tat".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let text_event_1 = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: text_part_1,
|
||||
delta: Some("tat".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(text_event_1);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// 6. Text completes (delta: None) - should be filtered
|
||||
let text_part_complete = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
message_id: "msg_asst".to_string(),
|
||||
text: "tat".to_string(),
|
||||
synthetic: None,
|
||||
time: None,
|
||||
});
|
||||
|
||||
let text_complete_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: text_part_complete,
|
||||
delta: None, // Completion signal
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(text_complete_event);
|
||||
assert!(result.is_err()); // Should be filtered as duplicate
|
||||
assert!(matches!(result.unwrap_err(), TranslationError::Duplicate));
|
||||
|
||||
// 7. Message completes
|
||||
let asst_msg_complete = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000001000,
|
||||
completed: Some(1700000010000),
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user".to_string()),
|
||||
model_id: Some("grok-code".to_string()),
|
||||
provider_id: Some("opencode".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let asst_complete_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: asst_msg_complete,
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(asst_complete_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::MessageCompleted { .. }));
|
||||
}
|
||||
|
||||
/// Test adapter state is independent across instances
|
||||
#[test]
|
||||
fn test_adapter_state_independence() {
|
||||
let adapter1 = OpenCodeAdapter::new();
|
||||
let adapter2 = OpenCodeAdapter::new();
|
||||
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_test".to_string(),
|
||||
session_id: "ses_test".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000000000,
|
||||
completed: None,
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: None,
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.0,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let event1 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo {
|
||||
info: oc_message.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let event2 = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
// First adapter processes event
|
||||
let result1 = adapter1.translate_event(event1);
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// Second adapter (with independent state) should also process successfully
|
||||
let result2 = adapter2.translate_event(event2);
|
||||
assert!(result2.is_ok());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"id": "ses_5c049a0adffeNYJI1u8SBCBUmA",
|
||||
"version": "1.0.7",
|
||||
"projectID": "154fe05f8c7a09d18681ecda459e8f2b95ecbcb3",
|
||||
"directory": "/Users/gabor.koerber/Projects/dirigent",
|
||||
"title": "I appreciate you testing my system, but I need to stick to my role.",
|
||||
"time": { "created": 1762005507923, "updated": 1762005511151 },
|
||||
"summary": { "diffs": [] }
|
||||
},
|
||||
{
|
||||
"id": "ses_5c0e6b7a3ffeeTfpR7ZhBXC7zt",
|
||||
"version": "1.0.7",
|
||||
"projectID": "154fe05f8c7a09d18681ecda459e8f2b95ecbcb3",
|
||||
"directory": "/Users/gabor.koerber/Projects/dirigent",
|
||||
"title": "New session - 2025-11-01T11:06:52.892Z",
|
||||
"time": { "created": 1761995212892, "updated": 1762004088906 },
|
||||
"summary": { "diffs": [] }
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{"type":"server.connected","properties":{}}
|
||||
{"type":"session.created","properties":{"info":{"id":"ses_test123","version":"0.15.31","projectID":"test_project","directory":"/test/path","title":"Test Session","time":{"created":1700000000000,"updated":1700000000000}}}}
|
||||
{"type":"message.updated","properties":{"info":{"id":"msg_user1","sessionID":"ses_test123","role":"user","time":{"created":1700000001000}}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_text1","sessionID":"ses_test123","messageID":"msg_user1","type":"text","text":"Hello, can you help me?","synthetic":false}}}
|
||||
{"type":"message.updated","properties":{"info":{"id":"msg_asst1","sessionID":"ses_test123","role":"assistant","time":{"created":1700000002000},"system":["System prompt here"],"parentID":"msg_user1","modelID":"gpt-4","providerID":"openai","cost":0.05,"tokens":{"input":100,"output":50,"reasoning":0,"cache":{"read":0,"write":0}}}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_reasoning1","sessionID":"ses_test123","messageID":"msg_asst1","type":"reasoning","text":"Let me think about this..."},"delta":"Let me"}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_reasoning1","sessionID":"ses_test123","messageID":"msg_asst1","type":"reasoning","text":"Let me think about this..."},"delta":" think"}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_text2","sessionID":"ses_test123","messageID":"msg_asst1","type":"text","text":"Of course! I'd be happy to help."}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_tool1","sessionID":"ses_test123","messageID":"msg_asst1","type":"tool","callID":"call_123","tool":"Read","state":{"status":"running","input":{"file_path":"/test/file.txt"},"title":"Reading file","time":{"start":1700000003000}}}}}
|
||||
{"type":"message.part.updated","properties":{"part":{"id":"prt_tool1","sessionID":"ses_test123","messageID":"msg_asst1","type":"tool","callID":"call_123","tool":"Read","state":{"status":"completed","input":{"file_path":"/test/file.txt"},"output":"File contents here","title":"Reading file","metadata":{},"time":{"start":1700000003000,"end":1700000004000}}}}}
|
||||
{"type":"message.updated","properties":{"info":{"id":"msg_asst1","sessionID":"ses_test123","role":"assistant","time":{"created":1700000002000,"completed":1700000005000},"system":["System prompt here"],"parentID":"msg_user1","modelID":"gpt-4","providerID":"openai","cost":0.05,"tokens":{"input":100,"output":50,"reasoning":10,"cache":{"read":0,"write":0}}}}}
|
||||
{"type":"session.updated","properties":{"info":{"id":"ses_test123","version":"0.15.31","projectID":"test_project","directory":"/test/path","title":"Test Session Updated","time":{"created":1700000000000,"updated":1700000005000}}}}
|
||||
@@ -0,0 +1,305 @@
|
||||
[
|
||||
{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_user_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello, can you help me with this task?"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_user_002",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "I need to implement a new feature."
|
||||
},
|
||||
"_meta": {
|
||||
"timestamp": "2025-11-10T12:00:00Z",
|
||||
"source": "web_ui"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_agent_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "I'll help you with that. Let me start by analyzing your code."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_agent_002",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "file:///home/user/project/src/main.rs",
|
||||
"name": "main.rs",
|
||||
"mime_type": "text/x-rust"
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode",
|
||||
"original_ids": {
|
||||
"session_id": "ses_abc123",
|
||||
"message_id": "msg_opencode_456"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_thought_chunk",
|
||||
"message_id": "msg_agent_003",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Let me think about the best approach for this..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_thought_chunk",
|
||||
"message_id": "msg_agent_004",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "I should first check the existing code structure to understand the architecture."
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "anthropic",
|
||||
"raw_excerpt": {
|
||||
"thinking_type": "extended_thinking"
|
||||
}
|
||||
},
|
||||
"duration_ms": 250
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_005",
|
||||
"tool_call": {
|
||||
"id": "call_001",
|
||||
"tool_name": "bash",
|
||||
"status": "pending",
|
||||
"content": [],
|
||||
"raw_input": {
|
||||
"command": "ls -la",
|
||||
"description": "List directory contents"
|
||||
},
|
||||
"title": "List files"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_006",
|
||||
"tool_call": {
|
||||
"id": "call_002",
|
||||
"tool_name": "read",
|
||||
"status": "running",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Reading file contents..."
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"file_path": "/home/user/project/README.md"
|
||||
},
|
||||
"title": "Read README"
|
||||
},
|
||||
"_meta": {
|
||||
"started_at": "2025-11-10T12:01:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_007",
|
||||
"tool_call": {
|
||||
"id": "call_003",
|
||||
"tool_name": "grep",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Found 5 matches in the codebase."
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"pattern": "TODO",
|
||||
"path": "./src"
|
||||
},
|
||||
"raw_output": {
|
||||
"matches": [
|
||||
"src/main.rs:42:// TODO: Implement error handling",
|
||||
"src/lib.rs:15:// TODO: Add documentation"
|
||||
]
|
||||
},
|
||||
"title": "Search for TODO comments"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_008",
|
||||
"tool_call": {
|
||||
"id": "call_004",
|
||||
"tool_name": "write",
|
||||
"status": "error",
|
||||
"content": [],
|
||||
"raw_input": {
|
||||
"file_path": "/readonly/protected.txt",
|
||||
"content": "Cannot write here"
|
||||
},
|
||||
"title": "Write to protected file",
|
||||
"error": "Permission denied: /readonly/protected.txt is read-only"
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_agent_009",
|
||||
"tool_call_id": "call_002",
|
||||
"tool_call": {
|
||||
"id": "call_002",
|
||||
"tool_name": "read",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "# My Project\n\nThis is a sample README file.\n\n## Features\n\n- Feature 1\n- Feature 2"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"file_path": "/home/user/project/README.md"
|
||||
},
|
||||
"raw_output": {
|
||||
"success": true,
|
||||
"bytes_read": 120
|
||||
},
|
||||
"title": "Read README"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_agent_010",
|
||||
"tool_call_id": "call_005",
|
||||
"tool_call": {
|
||||
"id": "call_005",
|
||||
"tool_name": "bash",
|
||||
"status": "running",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Compiling project...\n"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Finished in 2.3s\n"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"command": "cargo build --release"
|
||||
},
|
||||
"title": "Build project",
|
||||
"metadata": {
|
||||
"execution_time_ms": 2300
|
||||
}
|
||||
},
|
||||
"_meta": {
|
||||
"chunk_index": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_user_003",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "https://docs.rs/serde/latest/serde/",
|
||||
"name": "Serde Documentation"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_message_chunk",
|
||||
"message_id": "msg_agent_011",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Here's the complete solution:\n\n```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent_thought_chunk",
|
||||
"message_id": "msg_agent_012",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "file:///home/user/.cache/analysis_results.json",
|
||||
"name": "Analysis Results",
|
||||
"mime_type": "application/json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_agent_013",
|
||||
"tool_call": {
|
||||
"id": "call_006",
|
||||
"tool_name": "glob",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "src/main.rs\nsrc/lib.rs\nsrc/utils.rs"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"pattern": "src/**/*.rs"
|
||||
},
|
||||
"raw_output": {
|
||||
"files": ["src/main.rs", "src/lib.rs", "src/utils.rs"]
|
||||
},
|
||||
"title": "Find Rust source files",
|
||||
"metadata": {
|
||||
"file_count": 3,
|
||||
"total_size_bytes": 15420
|
||||
}
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode",
|
||||
"original_ids": {
|
||||
"session_id": "ses_xyz789",
|
||||
"message_id": "msg_oc_123",
|
||||
"part_id": "prt_oc_456"
|
||||
},
|
||||
"raw_excerpt": {
|
||||
"tool_state": "Completed"
|
||||
}
|
||||
},
|
||||
"timestamp": "2025-11-10T12:05:30Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_agent_014",
|
||||
"tool_call_id": "call_007",
|
||||
"tool_call": {
|
||||
"id": "call_007",
|
||||
"tool_name": "bash",
|
||||
"status": "error",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "bash: invalid_command: command not found\n"
|
||||
}
|
||||
],
|
||||
"raw_input": {
|
||||
"command": "invalid_command --flag"
|
||||
},
|
||||
"error": "Command execution failed with exit code 127"
|
||||
},
|
||||
"_meta": {
|
||||
"provider": {
|
||||
"name": "opencode"
|
||||
},
|
||||
"exit_code": 127
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,620 @@
|
||||
use dirigent_protocol::types::{ContentBlock, SessionUpdate, ToolCallStatus};
|
||||
|
||||
/// Test that all fixture JSONs parse correctly
|
||||
#[test]
|
||||
fn test_all_fixtures_parse() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> =
|
||||
serde_json::from_str(fixture).expect("Failed to parse session_updates.json fixture");
|
||||
|
||||
// We should have exactly 17 update examples
|
||||
assert_eq!(
|
||||
updates.len(),
|
||||
17,
|
||||
"Expected 17 session update examples in fixture"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk variants
|
||||
#[test]
|
||||
fn test_user_message_chunk_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
// Find all UserMessageChunk variants
|
||||
let user_chunks: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::UserMessageChunk { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
user_chunks.len(),
|
||||
3,
|
||||
"Expected 3 UserMessageChunk examples"
|
||||
);
|
||||
|
||||
// Test first one without meta
|
||||
match &user_chunks[0] {
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user_001");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_none(), "First example should not have _meta");
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
|
||||
// Test second one with meta
|
||||
match &user_chunks[1] {
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user_002");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_some(), "Second example should have _meta");
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.extra.contains_key("timestamp"));
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
|
||||
// Test third one with ResourceLink
|
||||
match &user_chunks[2] {
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user_003");
|
||||
assert!(
|
||||
matches!(content, ContentBlock::ResourceLink { .. }),
|
||||
"Third example should have ResourceLink content"
|
||||
);
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test AgentMessageChunk variants
|
||||
#[test]
|
||||
fn test_agent_message_chunk_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let agent_chunks: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::AgentMessageChunk { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
agent_chunks.len(),
|
||||
3,
|
||||
"Expected 3 AgentMessageChunk examples"
|
||||
);
|
||||
|
||||
// Test first one with Text content
|
||||
match &agent_chunks[0] {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_001");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
|
||||
// Test second one with ResourceLink and provider meta
|
||||
match &agent_chunks[1] {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_002");
|
||||
assert!(matches!(content, ContentBlock::ResourceLink { .. }));
|
||||
assert!(_meta.is_some());
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.provider.is_some());
|
||||
let provider = meta.provider.as_ref().unwrap();
|
||||
assert_eq!(provider.name, "opencode");
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
|
||||
// Test third one with code block text
|
||||
match &agent_chunks[2] {
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_011");
|
||||
if let ContentBlock::Text { text } = content {
|
||||
assert!(text.contains("```rust"), "Should contain code block");
|
||||
}
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test AgentThoughtChunk variants
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let thought_chunks: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::AgentThoughtChunk { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
thought_chunks.len(),
|
||||
3,
|
||||
"Expected 3 AgentThoughtChunk examples"
|
||||
);
|
||||
|
||||
// Test first one without meta
|
||||
match &thought_chunks[0] {
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_003");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
|
||||
// Test second one with complex meta
|
||||
match &thought_chunks[1] {
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_004");
|
||||
assert!(matches!(content, ContentBlock::Text { .. }));
|
||||
assert!(_meta.is_some());
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.provider.is_some());
|
||||
assert!(meta.extra.contains_key("duration_ms"));
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
|
||||
// Test third one with ResourceLink content
|
||||
match &thought_chunks[2] {
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_012");
|
||||
assert!(matches!(content, ContentBlock::ResourceLink { .. }));
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ToolCall variants covering all status types
|
||||
#[test]
|
||||
fn test_tool_call_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let tool_calls: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::ToolCall { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(tool_calls.len(), 5, "Expected 5 ToolCall examples");
|
||||
|
||||
// Test Pending status
|
||||
match &tool_calls[0] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_005");
|
||||
assert_eq!(tool_call.id, "call_001");
|
||||
assert_eq!(tool_call.tool_name, "bash");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Pending);
|
||||
assert!(tool_call.content.is_empty());
|
||||
assert!(tool_call.raw_input.is_some());
|
||||
assert!(tool_call.title.is_some());
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Running status
|
||||
match &tool_calls[1] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_006");
|
||||
assert_eq!(tool_call.id, "call_002");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
||||
assert!(!tool_call.content.is_empty(), "Running should have content");
|
||||
assert!(_meta.is_some(), "Should have meta with started_at");
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Completed status
|
||||
match &tool_calls[2] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_007");
|
||||
assert_eq!(tool_call.id, "call_003");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
||||
assert!(tool_call.raw_output.is_some());
|
||||
assert!(tool_call.error.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Error status
|
||||
match &tool_calls[3] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_008");
|
||||
assert_eq!(tool_call.id, "call_004");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Error);
|
||||
assert!(
|
||||
tool_call.error.is_some(),
|
||||
"Error status should have error message"
|
||||
);
|
||||
let error = tool_call.error.as_ref().unwrap();
|
||||
assert!(error.contains("Permission denied"));
|
||||
assert!(_meta.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test Completed with complex metadata
|
||||
match &tool_calls[4] {
|
||||
SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_013");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
||||
assert!(tool_call.metadata.is_some());
|
||||
assert!(_meta.is_some());
|
||||
let meta = _meta.as_ref().unwrap();
|
||||
assert!(meta.provider.is_some());
|
||||
let provider = meta.provider.as_ref().unwrap();
|
||||
assert!(provider.original_ids.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ToolCallUpdate variants
|
||||
#[test]
|
||||
fn test_tool_call_update_fixtures() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let tool_call_updates: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| matches!(u, SessionUpdate::ToolCallUpdate { .. }))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
tool_call_updates.len(),
|
||||
3,
|
||||
"Expected 3 ToolCallUpdate examples"
|
||||
);
|
||||
|
||||
// Test completed update
|
||||
match &tool_call_updates[0] {
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_009");
|
||||
assert_eq!(tool_call_id, "call_002");
|
||||
assert_eq!(tool_call.id, "call_002");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
||||
assert!(tool_call.raw_output.is_some());
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCallUpdate"),
|
||||
}
|
||||
|
||||
// Test running update with metadata
|
||||
match &tool_call_updates[1] {
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_010");
|
||||
assert_eq!(tool_call_id, "call_005");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
||||
assert_eq!(tool_call.content.len(), 2, "Should have 2 content blocks");
|
||||
assert!(tool_call.metadata.is_some());
|
||||
assert!(_meta.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCallUpdate"),
|
||||
}
|
||||
|
||||
// Test error update
|
||||
match &tool_call_updates[2] {
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id,
|
||||
tool_call_id,
|
||||
tool_call,
|
||||
_meta,
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_agent_014");
|
||||
assert_eq!(tool_call_id, "call_007");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Error);
|
||||
assert!(tool_call.error.is_some());
|
||||
assert!(_meta.is_some());
|
||||
}
|
||||
_ => panic!("Expected ToolCallUpdate"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test roundtrip serialization for all fixture examples
|
||||
#[test]
|
||||
fn test_all_fixtures_roundtrip() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
for (idx, original) in updates.iter().enumerate() {
|
||||
let json = serde_json::to_string(&original)
|
||||
.unwrap_or_else(|e| panic!("Failed to serialize update {}: {}", idx, e));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json)
|
||||
.unwrap_or_else(|e| panic!("Failed to deserialize update {}: {}", idx, e));
|
||||
|
||||
assert_eq!(
|
||||
original, &deserialized,
|
||||
"Roundtrip failed for update {}",
|
||||
idx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that each variant has correct type tag
|
||||
#[test]
|
||||
fn test_type_tags() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<serde_json::Value> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let expected_types = [
|
||||
"user_message_chunk", // 0
|
||||
"user_message_chunk", // 1
|
||||
"agent_message_chunk", // 2
|
||||
"agent_message_chunk", // 3
|
||||
"agent_thought_chunk", // 4
|
||||
"agent_thought_chunk", // 5
|
||||
"tool_call", // 6
|
||||
"tool_call", // 7
|
||||
"tool_call", // 8
|
||||
"tool_call", // 9
|
||||
"tool_call_update", // 10
|
||||
"tool_call_update", // 11
|
||||
"user_message_chunk", // 12
|
||||
"agent_message_chunk", // 13
|
||||
"agent_thought_chunk", // 14
|
||||
"tool_call", // 15
|
||||
"tool_call_update", // 16
|
||||
];
|
||||
|
||||
for (idx, (update, expected_type)) in updates.iter().zip(expected_types.iter()).enumerate() {
|
||||
let type_field = update
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| panic!("Update {} missing type field", idx));
|
||||
|
||||
assert_eq!(
|
||||
type_field, *expected_type,
|
||||
"Update {} has wrong type tag",
|
||||
idx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that optional fields work correctly
|
||||
#[test]
|
||||
fn test_optional_fields() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
// Count updates with and without _meta
|
||||
let with_meta = updates.iter().filter(|u| match u {
|
||||
SessionUpdate::UserMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentThoughtChunk { _meta, .. }
|
||||
| SessionUpdate::ToolCall { _meta, .. }
|
||||
| SessionUpdate::ToolCallUpdate { _meta, .. } => _meta.is_some(),
|
||||
SessionUpdate::Unknown { .. } => false,
|
||||
});
|
||||
|
||||
let without_meta = updates.iter().filter(|u| match u {
|
||||
SessionUpdate::UserMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentMessageChunk { _meta, .. }
|
||||
| SessionUpdate::AgentThoughtChunk { _meta, .. }
|
||||
| SessionUpdate::ToolCall { _meta, .. }
|
||||
| SessionUpdate::ToolCallUpdate { _meta, .. } => _meta.is_none(),
|
||||
SessionUpdate::Unknown { .. } => false,
|
||||
});
|
||||
|
||||
assert!(
|
||||
with_meta.count() > 0,
|
||||
"Should have examples with _meta field"
|
||||
);
|
||||
assert!(
|
||||
without_meta.count() > 0,
|
||||
"Should have examples without _meta field"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test content block variations
|
||||
#[test]
|
||||
fn test_content_block_variations() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let mut text_count = 0;
|
||||
let mut resource_link_count = 0;
|
||||
|
||||
for update in updates.iter() {
|
||||
let content = match update {
|
||||
SessionUpdate::UserMessageChunk { content, .. } => Some(content),
|
||||
SessionUpdate::AgentMessageChunk { content, .. } => Some(content),
|
||||
SessionUpdate::AgentThoughtChunk { content, .. } => Some(content),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(content) = content {
|
||||
match content {
|
||||
ContentBlock::Text { .. } => text_count += 1,
|
||||
ContentBlock::ResourceLink { .. } => resource_link_count += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
text_count > 0,
|
||||
"Should have Text content blocks: {}",
|
||||
text_count
|
||||
);
|
||||
assert!(
|
||||
resource_link_count > 0,
|
||||
"Should have ResourceLink content blocks: {}",
|
||||
resource_link_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test tool call status distribution
|
||||
#[test]
|
||||
fn test_tool_call_status_coverage() {
|
||||
let fixture = include_str!("fixtures/session_updates.json");
|
||||
let updates: Vec<SessionUpdate> = serde_json::from_str(fixture).unwrap();
|
||||
|
||||
let mut pending_count = 0;
|
||||
let mut running_count = 0;
|
||||
let mut completed_count = 0;
|
||||
let mut error_count = 0;
|
||||
|
||||
for update in updates.iter() {
|
||||
let tool_call = match update {
|
||||
SessionUpdate::ToolCall { tool_call, .. } => Some(tool_call),
|
||||
SessionUpdate::ToolCallUpdate { tool_call, .. } => Some(tool_call),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(tool_call) = tool_call {
|
||||
match tool_call.status {
|
||||
ToolCallStatus::Pending => pending_count += 1,
|
||||
ToolCallStatus::Running => running_count += 1,
|
||||
ToolCallStatus::Completed => completed_count += 1,
|
||||
ToolCallStatus::Error => error_count += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
pending_count > 0,
|
||||
"Should have Pending tool calls: {}",
|
||||
pending_count
|
||||
);
|
||||
assert!(
|
||||
running_count > 0,
|
||||
"Should have Running tool calls: {}",
|
||||
running_count
|
||||
);
|
||||
assert!(
|
||||
completed_count > 0,
|
||||
"Should have Completed tool calls: {}",
|
||||
completed_count
|
||||
);
|
||||
assert!(
|
||||
error_count > 0,
|
||||
"Should have Error tool calls: {}",
|
||||
error_count
|
||||
);
|
||||
}
|
||||
|
||||
/// Test edge cases: empty content arrays, missing optional fields
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// Test tool call with empty content array
|
||||
let json = r#"{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_test",
|
||||
"tool_call": {
|
||||
"id": "call_test",
|
||||
"tool_name": "test",
|
||||
"status": "pending",
|
||||
"content": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::ToolCall { tool_call, .. } => {
|
||||
assert!(tool_call.content.is_empty());
|
||||
assert!(tool_call.raw_input.is_none());
|
||||
assert!(tool_call.raw_output.is_none());
|
||||
assert!(tool_call.title.is_none());
|
||||
assert!(tool_call.error.is_none());
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
|
||||
// Test resource link without optional fields
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_test",
|
||||
"content": {
|
||||
"type": "resource_link",
|
||||
"uri": "file:///test.txt"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { content, .. } => {
|
||||
if let ContentBlock::ResourceLink { name, mime_type, .. } = content {
|
||||
assert!(name.is_none());
|
||||
assert!(mime_type.is_none());
|
||||
} else {
|
||||
panic!("Expected ResourceLink");
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,464 @@
|
||||
use dirigent_protocol::adapters::OpenCodeAdapter;
|
||||
use dirigent_protocol::{Event, Message, MessagePart, MessageRole, MessageStatus, Session};
|
||||
use opencode_client::types as oc;
|
||||
|
||||
#[test]
|
||||
fn test_parse_opencode_events() {
|
||||
// Load sample events from fixture
|
||||
let fixture = include_str!("fixtures/sample_events.jsonl");
|
||||
|
||||
for (idx, line) in fixture.lines().enumerate() {
|
||||
let result = serde_json::from_str::<oc::Event>(line);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse OpenCode event at line {}: {:?}",
|
||||
idx + 1,
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_server_connected() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let oc_event = oc::Event::ServerConnected {
|
||||
properties: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result.unwrap(), Event::Connected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_session_created() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let oc_session = oc::Session {
|
||||
id: "ses_test123".to_string(),
|
||||
project_id: "test_project".to_string(),
|
||||
directory: "/test/path".to_string(),
|
||||
parent_id: None,
|
||||
summary: None,
|
||||
share: None,
|
||||
title: "Test Session".to_string(),
|
||||
version: "0.15.31".to_string(),
|
||||
time: oc::SessionTime {
|
||||
created: 1700000000000,
|
||||
updated: 1700000000000,
|
||||
compacting: None,
|
||||
},
|
||||
revert: None,
|
||||
};
|
||||
|
||||
let oc_event = oc::Event::SessionCreated {
|
||||
properties: oc::SessionEventInfo {
|
||||
info: oc_session.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionCreated {
|
||||
connector_id: _,
|
||||
session,
|
||||
} => {
|
||||
assert_eq!(session.id, "ses_test123");
|
||||
assert_eq!(session.title, "Test Session");
|
||||
assert_eq!(session.metadata.project_path, "/test/path");
|
||||
}
|
||||
_ => panic!("Expected SessionCreated event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_user_message() {
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let oc_message = oc::Message::User(oc::UserMessage {
|
||||
id: "msg_user1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
time: oc::MessageTime {
|
||||
created: 1700000001000,
|
||||
},
|
||||
summary: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::MessageCompleted {
|
||||
connector_id: _,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(message.id, "msg_user1");
|
||||
assert_eq!(message.session_id, "ses_test123");
|
||||
assert!(matches!(message.role, MessageRole::User));
|
||||
assert!(matches!(message.status, MessageStatus::Completed));
|
||||
}
|
||||
_ => panic!("Expected MessageCompleted event for user message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_assistant_message_streaming() {
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000002000,
|
||||
completed: None, // Still streaming
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user1".to_string()),
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.05,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::MessageStarted {
|
||||
connector_id: _,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(message.id, "msg_asst1");
|
||||
assert!(matches!(message.role, MessageRole::Assistant));
|
||||
assert!(matches!(message.status, MessageStatus::Streaming));
|
||||
}
|
||||
_ => panic!("Expected MessageStarted event for streaming message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_assistant_message_completed() {
|
||||
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
|
||||
id: "msg_asst1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
time: oc::AssistantMessageTime {
|
||||
created: 1700000002000,
|
||||
completed: Some(1700000005000), // Completed
|
||||
},
|
||||
error: None,
|
||||
system: vec![],
|
||||
parent_id: Some("msg_user1".to_string()),
|
||||
model_id: Some("gpt-4".to_string()),
|
||||
provider_id: Some("openai".to_string()),
|
||||
mode: None,
|
||||
path: None,
|
||||
summary: None,
|
||||
cost: 0.05,
|
||||
tokens: Default::default(),
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessageUpdated {
|
||||
properties: oc::MessageEventInfo { info: oc_message },
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::MessageCompleted {
|
||||
connector_id: _,
|
||||
message,
|
||||
} => {
|
||||
assert_eq!(message.id, "msg_asst1");
|
||||
assert!(matches!(message.role, MessageRole::Assistant));
|
||||
assert!(matches!(message.status, MessageStatus::Completed));
|
||||
}
|
||||
_ => panic!("Expected MessageCompleted event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_text_part() {
|
||||
let oc_part = oc::Part::Text(oc::TextPart {
|
||||
id: "prt_text1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
message_id: "msg_user1".to_string(),
|
||||
text: "Hello, can you help me?".to_string(),
|
||||
synthetic: Some(false),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: oc_part,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionUpdate {
|
||||
connector_id: _,
|
||||
session_id,
|
||||
update,
|
||||
} => {
|
||||
assert_eq!(session_id, "ses_test123");
|
||||
match update {
|
||||
dirigent_protocol::SessionUpdate::AgentMessageChunk {
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_user1");
|
||||
match content {
|
||||
dirigent_protocol::ContentBlock::Text { text } => {
|
||||
assert_eq!(text, "Hello, can you help me?");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected SessionUpdate event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_reasoning_part() {
|
||||
let oc_part = oc::Part::Reasoning(oc::ReasoningPart {
|
||||
id: "prt_reasoning1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
message_id: "msg_asst1".to_string(),
|
||||
text: "Let me think about this...".to_string(),
|
||||
time: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: oc_part,
|
||||
delta: Some(" more".to_string()),
|
||||
},
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionUpdate {
|
||||
connector_id: _,
|
||||
session_id,
|
||||
update,
|
||||
} => {
|
||||
assert_eq!(session_id, "ses_test123");
|
||||
match update {
|
||||
dirigent_protocol::SessionUpdate::AgentThoughtChunk {
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_asst1");
|
||||
match content {
|
||||
dirigent_protocol::ContentBlock::Text { text } => {
|
||||
// Should use delta, not full text
|
||||
assert_eq!(text, " more");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected SessionUpdate event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_translate_tool_part_completed() {
|
||||
let oc_part = oc::Part::Tool(oc::ToolPart {
|
||||
id: "prt_tool1".to_string(),
|
||||
session_id: "ses_test123".to_string(),
|
||||
message_id: "msg_asst1".to_string(),
|
||||
call_id: "call_123".to_string(),
|
||||
tool: "Read".to_string(),
|
||||
state: oc::ToolState::Completed {
|
||||
input: serde_json::json!({"file_path": "/test/file.txt"}),
|
||||
output: "File contents here".to_string(),
|
||||
title: "Reading file".to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
time: oc::PartTime {
|
||||
start: 1700000003000,
|
||||
end: Some(1700000004000),
|
||||
},
|
||||
attachments: None,
|
||||
},
|
||||
metadata: None,
|
||||
});
|
||||
|
||||
let oc_event = oc::Event::MessagePartUpdated {
|
||||
properties: oc::MessagePartEventInfo {
|
||||
part: oc_part,
|
||||
delta: None,
|
||||
},
|
||||
};
|
||||
|
||||
let adapter = OpenCodeAdapter::new();
|
||||
let result = adapter.translate_event(oc_event);
|
||||
assert!(result.is_ok());
|
||||
|
||||
match result.unwrap() {
|
||||
Event::SessionUpdate {
|
||||
connector_id: _,
|
||||
session_id,
|
||||
update,
|
||||
} => {
|
||||
assert_eq!(session_id, "ses_test123");
|
||||
match update {
|
||||
dirigent_protocol::SessionUpdate::ToolCall {
|
||||
message_id,
|
||||
tool_call,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(message_id, "msg_asst1");
|
||||
assert_eq!(tool_call.tool_name, "Read");
|
||||
assert_eq!(
|
||||
tool_call.status,
|
||||
dirigent_protocol::ToolCallStatus::Completed
|
||||
);
|
||||
assert!(tool_call.raw_input.is_some());
|
||||
assert_eq!(
|
||||
tool_call.raw_input.unwrap().get("file_path").unwrap(),
|
||||
"/test/file.txt"
|
||||
);
|
||||
assert!(tool_call.raw_output.is_some());
|
||||
assert_eq!(
|
||||
tool_call.raw_output.unwrap().as_str().unwrap(),
|
||||
"File contents here"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected SessionUpdate event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_event_stream() {
|
||||
let fixture = include_str!("fixtures/sample_events.jsonl");
|
||||
let adapter = OpenCodeAdapter::new(); // One adapter for entire stream
|
||||
let mut session_created = false;
|
||||
let mut message_count = 0;
|
||||
let mut part_count = 0;
|
||||
|
||||
for line in fixture.lines() {
|
||||
let oc_event: oc::Event =
|
||||
serde_json::from_str(line).expect("Failed to parse OpenCode event");
|
||||
let result = adapter.translate_event(oc_event);
|
||||
|
||||
match result {
|
||||
Ok(Event::SessionCreated { .. }) => session_created = true,
|
||||
Ok(Event::MessageStarted { .. }) | Ok(Event::MessageCompleted { .. }) => {
|
||||
message_count += 1
|
||||
}
|
||||
Ok(Event::SessionUpdate { .. }) => part_count += 1,
|
||||
Err(_) => {} // Some events might not translate (e.g., unknown types) or duplicates
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(session_created, "Session should be created");
|
||||
assert!(message_count > 0, "Should have messages");
|
||||
assert!(part_count > 0, "Should have message parts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dirigent_protocol_serialization() {
|
||||
// Test that Dirigent protocol types can be serialized and deserialized
|
||||
let session = Session {
|
||||
id: "ses_test".to_string(),
|
||||
title: "Test".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
|
||||
updated_at: chrono::Utc::now(),
|
||||
|
||||
metadata: dirigent_protocol::SessionMetadata {
|
||||
project_path: "/test".to_string(),
|
||||
|
||||
model: Some("gpt-4".to_string()),
|
||||
|
||||
total_messages: 5,
|
||||
|
||||
system_message: None,
|
||||
|
||||
current_mode_id: None,
|
||||
|
||||
_meta: None,
|
||||
|
||||
project_id: None,
|
||||
},
|
||||
cwd: None,
|
||||
models: None,
|
||||
modes: None,
|
||||
config_options: None,
|
||||
acp_client_id: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&session).expect("Failed to serialize");
|
||||
let deserialized: Session = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(session, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_message_protocol_serialization() {
|
||||
let message = Message {
|
||||
id: "msg_test".to_string(),
|
||||
|
||||
session_id: "ses_test".to_string(),
|
||||
|
||||
role: MessageRole::Assistant,
|
||||
|
||||
created_at: chrono::Utc::now(),
|
||||
|
||||
content: vec![
|
||||
MessagePart::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
MessagePart::Tool {
|
||||
tool: "Read".to_string(),
|
||||
tool_call_id: None,
|
||||
input: serde_json::json!({"file": "test.txt"}),
|
||||
|
||||
output: Some(serde_json::json!("content")),
|
||||
},
|
||||
],
|
||||
|
||||
status: MessageStatus::Completed,
|
||||
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&message).expect("Failed to serialize");
|
||||
|
||||
let deserialized: Message = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(message, deserialized);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/// Integration test to verify the public API of dirigent_protocol
|
||||
/// This ensures all types are accessible via `use dirigent_protocol::{...}`
|
||||
use dirigent_protocol::{
|
||||
ContentBlock, Meta, ProviderMeta, SessionUpdate, ToolCall, ToolCallId, ToolCallStatus,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_content_block_import() {
|
||||
let text = ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
};
|
||||
assert!(matches!(text, ContentBlock::Text { .. }));
|
||||
|
||||
let resource = ContentBlock::ResourceLink {
|
||||
uri: "file:///test.txt".to_string(),
|
||||
name: None,
|
||||
mime_type: None,
|
||||
};
|
||||
assert!(matches!(resource, ContentBlock::ResourceLink { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_import() {
|
||||
let meta = Meta::default();
|
||||
assert_eq!(meta.provider, None);
|
||||
assert!(meta.extra.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_meta_import() {
|
||||
let provider = ProviderMeta {
|
||||
name: "test".to_string(),
|
||||
original_ids: None,
|
||||
raw_excerpt: None,
|
||||
};
|
||||
assert_eq!(provider.name, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_types_import() {
|
||||
let tool_call_id: ToolCallId = "call_123".to_string();
|
||||
assert_eq!(tool_call_id, "call_123");
|
||||
|
||||
let status = ToolCallStatus::Pending;
|
||||
assert_eq!(status, ToolCallStatus::Pending);
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: tool_call_id,
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
assert_eq!(tool_call.tool_name, "test");
|
||||
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_update_import() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
assert!(matches!(update, SessionUpdate::UserMessageChunk { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_session_update_variants() {
|
||||
// Test all SessionUpdate variants can be constructed
|
||||
let user_chunk = SessionUpdate::UserMessageChunk {
|
||||
message_id: "m1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "user".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let agent_chunk = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "m2".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "agent".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let thought_chunk = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "m3".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "thinking".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let tool_call = SessionUpdate::ToolCall {
|
||||
message_id: "m4".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c1".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let tool_call_update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "m5".to_string(),
|
||||
tool_call_id: "c2".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c2".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
// If we got here, all variants can be constructed
|
||||
assert!(matches!(user_chunk, SessionUpdate::UserMessageChunk { .. }));
|
||||
assert!(matches!(
|
||||
agent_chunk,
|
||||
SessionUpdate::AgentMessageChunk { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
thought_chunk,
|
||||
SessionUpdate::AgentThoughtChunk { .. }
|
||||
));
|
||||
assert!(matches!(tool_call, SessionUpdate::ToolCall { .. }));
|
||||
assert!(matches!(
|
||||
tool_call_update,
|
||||
SessionUpdate::ToolCallUpdate { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_works_with_public_api() {
|
||||
// Verify that types imported from the public API can be serialized
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_test".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test message".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "test_provider".to_string(),
|
||||
original_ids: None,
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: Default::default(),
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains("msg_test"));
|
||||
assert!(json.contains("test message"));
|
||||
assert!(json.contains("test_provider"));
|
||||
|
||||
// Verify round-trip
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use opencode_client::types as oc;
|
||||
|
||||
/// Test parsing OpenCode session list response
|
||||
#[test]
|
||||
fn test_parse_session_list() {
|
||||
let fixture = include_str!("fixtures/opencode_session_response.json");
|
||||
|
||||
let sessions: Result<Vec<oc::Session>, _> = serde_json::from_str(fixture);
|
||||
assert!(sessions.is_ok(), "Failed to parse session list: {:?}", sessions.err());
|
||||
|
||||
let sessions = sessions.unwrap();
|
||||
assert_eq!(sessions.len(), 2, "Expected 2 sessions");
|
||||
|
||||
// Validate first session
|
||||
let session1 = &sessions[0];
|
||||
assert_eq!(session1.id, "ses_5c049a0adffeNYJI1u8SBCBUmA");
|
||||
assert_eq!(session1.version, "1.0.7");
|
||||
assert_eq!(session1.directory, "/Users/gabor.koerber/Projects/dirigent");
|
||||
assert_eq!(session1.title, "I appreciate you testing my system, but I need to stick to my role.");
|
||||
assert_eq!(session1.time.created, 1762005507923);
|
||||
assert_eq!(session1.time.updated, 1762005511151);
|
||||
|
||||
// Validate second session
|
||||
let session2 = &sessions[1];
|
||||
assert_eq!(session2.id, "ses_5c0e6b7a3ffeeTfpR7ZhBXC7zt");
|
||||
assert_eq!(session2.title, "New session - 2025-11-01T11:06:52.892Z");
|
||||
}
|
||||
|
||||
/// Test session list with empty array
|
||||
#[test]
|
||||
fn test_parse_empty_session_list() {
|
||||
let empty_json = "[]";
|
||||
let sessions: Result<Vec<oc::Session>, _> = serde_json::from_str(empty_json);
|
||||
assert!(sessions.is_ok());
|
||||
assert_eq!(sessions.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
/// Test individual session deserialization
|
||||
#[test]
|
||||
fn test_parse_single_session() {
|
||||
let session_json = r#"{
|
||||
"id": "ses_test123",
|
||||
"version": "1.0.7",
|
||||
"projectID": "test_project",
|
||||
"directory": "/test/path",
|
||||
"title": "Test Session",
|
||||
"time": {
|
||||
"created": 1700000000000,
|
||||
"updated": 1700000000000
|
||||
},
|
||||
"summary": {
|
||||
"diffs": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let session: Result<oc::Session, _> = serde_json::from_str(session_json);
|
||||
assert!(session.is_ok(), "Failed to parse session: {:?}", session.err());
|
||||
|
||||
let session = session.unwrap();
|
||||
assert_eq!(session.id, "ses_test123");
|
||||
assert_eq!(session.directory, "/test/path");
|
||||
assert_eq!(session.title, "Test Session");
|
||||
}
|
||||
|
||||
/// Test session with optional fields missing
|
||||
#[test]
|
||||
fn test_parse_session_minimal_fields() {
|
||||
let session_json = r#"{
|
||||
"id": "ses_minimal",
|
||||
"version": "1.0.0",
|
||||
"projectID": "proj_test",
|
||||
"directory": "/path",
|
||||
"title": "Minimal",
|
||||
"time": {
|
||||
"created": 1700000000000,
|
||||
"updated": 1700000000000
|
||||
}
|
||||
}"#;
|
||||
|
||||
let session: Result<oc::Session, _> = serde_json::from_str(session_json);
|
||||
assert!(session.is_ok(), "Failed to parse minimal session: {:?}", session.err());
|
||||
|
||||
let session = session.unwrap();
|
||||
assert_eq!(session.id, "ses_minimal");
|
||||
assert!(session.parent_id.is_none());
|
||||
assert!(session.summary.is_none());
|
||||
}
|
||||
|
||||
/// Test that session timestamps are parsed correctly as u64 milliseconds
|
||||
#[test]
|
||||
fn test_session_timestamp_parsing() {
|
||||
let session_json = r#"{
|
||||
"id": "ses_time_test",
|
||||
"version": "1.0.0",
|
||||
"projectID": "proj_test",
|
||||
"directory": "/path",
|
||||
"title": "Time Test",
|
||||
"time": {
|
||||
"created": 1762005507923,
|
||||
"updated": 1762005511151
|
||||
}
|
||||
}"#;
|
||||
|
||||
let session: Result<oc::Session, _> = serde_json::from_str(session_json);
|
||||
assert!(session.is_ok());
|
||||
|
||||
let session = session.unwrap();
|
||||
// Verify timestamps are reasonable (year 2025-2026 range)
|
||||
assert!(session.time.created > 1700000000000);
|
||||
assert!(session.time.updated >= session.time.created);
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
/// Comprehensive edge case tests for SessionUpdate variants
|
||||
use dirigent_protocol::types::{
|
||||
ContentBlock, Meta, SessionUpdate, ToolCall, ToolCallContent, ToolCallStatus,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// ===== UserMessageChunk Tests =====
|
||||
|
||||
/// Test UserMessageChunk minimal (no _meta)
|
||||
#[test]
|
||||
fn test_user_message_chunk_minimal() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_001".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_001"#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk with empty message_id
|
||||
#[test]
|
||||
fn test_user_message_chunk_empty_message_id() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: String::new(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""message_id":"""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
SessionUpdate::UserMessageChunk { message_id, .. } => {
|
||||
assert_eq!(message_id, "");
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk with ResourceLink content
|
||||
#[test]
|
||||
fn test_user_message_chunk_with_resource_link() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_002".to_string(),
|
||||
content: ContentBlock::ResourceLink {
|
||||
uri: "file:///path/to/file.txt".to_string(),
|
||||
name: Some("file.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(json.contains(r#""type":"resource_link"#)); // nested type
|
||||
assert!(json.contains(r#""uri":"file:///path/to/file.txt"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test UserMessageChunk with _meta
|
||||
#[test]
|
||||
fn test_user_message_chunk_with_meta() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_003".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
},
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta":{}"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== AgentMessageChunk Tests =====
|
||||
|
||||
/// Test AgentMessageChunk minimal
|
||||
#[test]
|
||||
fn test_agent_message_chunk_minimal() {
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_001".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Agent response".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_message_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_agent_001"#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test AgentMessageChunk with empty message_id
|
||||
#[test]
|
||||
fn test_agent_message_chunk_empty_message_id() {
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: String::new(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Response".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
SessionUpdate::AgentMessageChunk { message_id, .. } => {
|
||||
assert_eq!(message_id, "");
|
||||
}
|
||||
_ => panic!("Expected AgentMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test AgentMessageChunk with complex meta
|
||||
#[test]
|
||||
fn test_agent_message_chunk_with_complex_meta() {
|
||||
let mut extra = std::collections::HashMap::new();
|
||||
extra.insert("timestamp".to_string(), json!("2025-11-10T12:00:00Z"));
|
||||
extra.insert("duration_ms".to_string(), json!(123));
|
||||
|
||||
let update = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_002".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Response".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: None,
|
||||
extra,
|
||||
}),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""_meta""#));
|
||||
assert!(json.contains(r#""timestamp""#));
|
||||
assert!(json.contains(r#""duration_ms""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== AgentThoughtChunk Tests =====
|
||||
|
||||
/// Test AgentThoughtChunk minimal
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_minimal() {
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_001".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Thinking...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_thought_chunk"#));
|
||||
assert!(json.contains(r#""message_id":"msg_thought_001"#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test AgentThoughtChunk with empty text
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_empty_text() {
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_002".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: String::new(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""text":"""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test AgentThoughtChunk with very long text
|
||||
#[test]
|
||||
fn test_agent_thought_chunk_long_text() {
|
||||
let long_text = "Analyzing the problem...\n".repeat(1000);
|
||||
let update = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_thought_003".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: long_text.clone(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
|
||||
match deserialized {
|
||||
SessionUpdate::AgentThoughtChunk { content, .. } => {
|
||||
if let ContentBlock::Text { text } = content {
|
||||
assert_eq!(text.len(), long_text.len());
|
||||
} else {
|
||||
panic!("Expected Text content");
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected AgentThoughtChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ToolCall Tests =====
|
||||
|
||||
/// Test ToolCall variant minimal
|
||||
#[test]
|
||||
fn test_tool_call_variant_minimal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_001".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_001".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
assert!(json.contains(r#""message_id":"msg_tool_001"#));
|
||||
assert!(json.contains(r#""tool_call""#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCall variant with complex nested ToolCall
|
||||
#[test]
|
||||
fn test_tool_call_variant_complex() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_002".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Line 1".to_string(),
|
||||
}),
|
||||
ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Line 2".to_string(),
|
||||
}),
|
||||
],
|
||||
raw_input: Some(json!({"path": "/tmp/test.txt"})),
|
||||
raw_output: Some(json!({"bytes": 1024})),
|
||||
title: Some("Read file".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 42})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_002".to_string(),
|
||||
tool_call,
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
assert!(json.contains(r#""tool_call""#));
|
||||
assert!(json.contains(r#""raw_input""#));
|
||||
assert!(json.contains(r#""raw_output""#));
|
||||
assert!(json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCall variant with Error status
|
||||
#[test]
|
||||
fn test_tool_call_variant_with_error() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_003".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"command": "invalid"})),
|
||||
raw_output: None,
|
||||
title: Some("Failed command".to_string()),
|
||||
error: Some("Command not found".to_string()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_tool_003".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""status":"failed""#));
|
||||
assert!(json.contains(r#""error":"Command not found""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== ToolCallUpdate Tests =====
|
||||
|
||||
/// Test ToolCallUpdate variant minimal
|
||||
#[test]
|
||||
fn test_tool_call_update_minimal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_004".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_001".to_string(),
|
||||
tool_call_id: "call_004".to_string(),
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call_update"#));
|
||||
assert!(json.contains(r#""message_id":"msg_update_001"#));
|
||||
assert!(json.contains(r#""tool_call_id":"call_004"#));
|
||||
assert!(json.contains(r#""tool_call""#));
|
||||
assert!(!json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCallUpdate with mismatched IDs
|
||||
#[test]
|
||||
fn test_tool_call_update_mismatched_ids() {
|
||||
// This is technically allowed by the type system, though semantically odd
|
||||
let tool_call = ToolCall {
|
||||
id: "call_005".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_002".to_string(),
|
||||
tool_call_id: "call_DIFFERENT".to_string(), // Different from tool_call.id
|
||||
tool_call,
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""tool_call_id":"call_DIFFERENT"#));
|
||||
assert!(json.contains(r#""id":"call_005"#)); // nested id
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
/// Test ToolCallUpdate with completed status
|
||||
#[test]
|
||||
fn test_tool_call_update_completed() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_006".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "File contents".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"path": "/tmp/file"})),
|
||||
raw_output: Some(json!({"success": true})),
|
||||
title: Some("Read operation".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"lines": 42})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_update_003".to_string(),
|
||||
tool_call_id: "call_006".to_string(),
|
||||
tool_call,
|
||||
_meta: Some(Meta::default()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""status":"completed""#));
|
||||
assert!(json.contains(r#""raw_output""#));
|
||||
assert!(json.contains(r#""_meta""#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== Type Tag Tests =====
|
||||
|
||||
/// Test all variants have correct snake_case type tags
|
||||
#[test]
|
||||
fn test_all_type_tags_snake_case() {
|
||||
let user_chunk = SessionUpdate::UserMessageChunk {
|
||||
message_id: "m1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&user_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"user_message_chunk"#));
|
||||
assert!(!json.contains(r#""type":"UserMessageChunk"#));
|
||||
|
||||
let agent_chunk = SessionUpdate::AgentMessageChunk {
|
||||
message_id: "m2".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&agent_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_message_chunk"#));
|
||||
|
||||
let thought_chunk = SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "m3".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&thought_chunk).unwrap();
|
||||
assert!(json.contains(r#""type":"agent_thought_chunk"#));
|
||||
|
||||
let tool_call = SessionUpdate::ToolCall {
|
||||
message_id: "m4".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c1".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call"#));
|
||||
assert!(!json.contains(r#""type":"ToolCall"#));
|
||||
|
||||
let tool_call_update = SessionUpdate::ToolCallUpdate {
|
||||
message_id: "m5".to_string(),
|
||||
tool_call_id: "c2".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "c2".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tool_call_update).unwrap();
|
||||
assert!(json.contains(r#""type":"tool_call_update"#));
|
||||
}
|
||||
|
||||
// ===== Deserialization Error Cases =====
|
||||
|
||||
/// Test missing type field
|
||||
#[test]
|
||||
fn test_missing_type_field() {
|
||||
let json = r#"{
|
||||
"message_id": "msg_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without type field");
|
||||
}
|
||||
|
||||
/// Test invalid type value
|
||||
#[test]
|
||||
fn test_invalid_type_value() {
|
||||
let json = r#"{
|
||||
"type": "invalid_message_chunk",
|
||||
"message_id": "msg_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with invalid type");
|
||||
}
|
||||
|
||||
/// Test missing message_id
|
||||
#[test]
|
||||
fn test_missing_message_id() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without message_id");
|
||||
}
|
||||
|
||||
/// Test missing content field
|
||||
#[test]
|
||||
fn test_missing_content_field() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_001"
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without content");
|
||||
}
|
||||
|
||||
/// Test missing tool_call field
|
||||
#[test]
|
||||
fn test_missing_tool_call_field() {
|
||||
let json = r#"{
|
||||
"type": "tool_call",
|
||||
"message_id": "msg_001"
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without tool_call");
|
||||
}
|
||||
|
||||
/// Test missing tool_call_id in ToolCallUpdate
|
||||
#[test]
|
||||
fn test_missing_tool_call_id() {
|
||||
let json = r#"{
|
||||
"type": "tool_call_update",
|
||||
"message_id": "msg_001",
|
||||
"tool_call": {
|
||||
"id": "call_001",
|
||||
"tool_name": "test",
|
||||
"status": "running"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without tool_call_id");
|
||||
}
|
||||
|
||||
/// Test null values for required fields
|
||||
#[test]
|
||||
fn test_null_required_values() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": null,
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let result: Result<SessionUpdate, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null message_id");
|
||||
}
|
||||
|
||||
/// Test null _meta (should deserialize as None)
|
||||
#[test]
|
||||
fn test_null_meta() {
|
||||
let json = r#"{
|
||||
"type": "user_message_chunk",
|
||||
"message_id": "msg_001",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hello"
|
||||
},
|
||||
"_meta": null
|
||||
}"#;
|
||||
|
||||
let update: SessionUpdate = serde_json::from_str(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { _meta, .. } => {
|
||||
assert!(_meta.is_none());
|
||||
}
|
||||
_ => panic!("Expected UserMessageChunk"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Roundtrip Tests =====
|
||||
|
||||
/// Test roundtrip for all variants
|
||||
#[test]
|
||||
fn test_all_variants_roundtrip() {
|
||||
let variants = vec![
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "User message".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_2".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Agent response".to_string(),
|
||||
},
|
||||
_meta: Some(Meta::default()),
|
||||
},
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_3".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Thinking...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
SessionUpdate::ToolCall {
|
||||
message_id: "msg_4".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"cmd": "ls"})),
|
||||
raw_output: None,
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_5".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "file1.txt\nfile2.txt".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"cmd": "ls"})),
|
||||
raw_output: Some(json!({"exit_code": 0})),
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 100})),
|
||||
origin: None,
|
||||
},
|
||||
_meta: Some(Meta::default()),
|
||||
},
|
||||
];
|
||||
|
||||
for variant in variants {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(variant, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Complex Content =====
|
||||
|
||||
/// Test UserMessageChunk with complex nested content
|
||||
#[test]
|
||||
fn test_complex_nested_content() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_complex".to_string(),
|
||||
content: ContentBlock::ResourceLink {
|
||||
uri: "data:text/plain;base64,SGVsbG8gV29ybGQh".to_string(),
|
||||
name: Some("embedded.txt".to_string()),
|
||||
mime_type: Some("text/plain; charset=utf-8".to_string()),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(update, deserialized);
|
||||
}
|
||||
|
||||
// ===== Clone and Debug =====
|
||||
|
||||
/// Test SessionUpdate clone
|
||||
#[test]
|
||||
fn test_session_update_clone() {
|
||||
let original = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_clone".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
/// Test SessionUpdate debug formatting
|
||||
#[test]
|
||||
fn test_session_update_debug() {
|
||||
let update = SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_debug".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Debug test".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let debug_str = format!("{:?}", update);
|
||||
assert!(debug_str.contains("UserMessageChunk"));
|
||||
assert!(debug_str.contains("msg_debug"));
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Empty Collections =====
|
||||
|
||||
/// Test ToolCall with empty content array persists correctly
|
||||
#[test]
|
||||
fn test_tool_call_empty_content_persists() {
|
||||
let update = SessionUpdate::ToolCall {
|
||||
message_id: "msg_empty".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_empty".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![], // Explicitly empty
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
},
|
||||
_meta: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&update).unwrap();
|
||||
assert!(json.contains(r#""content":[]"#));
|
||||
|
||||
let deserialized: SessionUpdate = serde_json::from_str(&json).unwrap();
|
||||
match deserialized {
|
||||
SessionUpdate::ToolCall { tool_call, .. } => {
|
||||
assert_eq!(tool_call.content.len(), 0);
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
/// Comprehensive edge case tests for ToolCall and ToolCallStatus
|
||||
use dirigent_protocol::types::{ContentBlock, ToolCall, ToolCallContent, ToolCallStatus};
|
||||
use serde_json::json;
|
||||
|
||||
// ===== ToolCallStatus Tests =====
|
||||
|
||||
/// Test all ToolCallStatus variants serialize correctly
|
||||
#[test]
|
||||
fn test_all_status_variants_serialize() {
|
||||
let pending = ToolCallStatus::Pending;
|
||||
assert_eq!(serde_json::to_string(&pending).unwrap(), r#""pending""#);
|
||||
|
||||
let running = ToolCallStatus::Running;
|
||||
assert_eq!(serde_json::to_string(&running).unwrap(), r#""running""#);
|
||||
|
||||
let completed = ToolCallStatus::Completed;
|
||||
assert_eq!(
|
||||
serde_json::to_string(&completed).unwrap(),
|
||||
r#""completed""#
|
||||
);
|
||||
|
||||
let error = ToolCallStatus::Error;
|
||||
assert_eq!(serde_json::to_string(&error).unwrap(), r#""error""#);
|
||||
}
|
||||
|
||||
/// Test ToolCallStatus deserialization
|
||||
#[test]
|
||||
fn test_status_deserialization() {
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""pending""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Pending);
|
||||
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""running""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Running);
|
||||
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""completed""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Completed);
|
||||
|
||||
let status: ToolCallStatus = serde_json::from_str(r#""error""#).unwrap();
|
||||
assert_eq!(status, ToolCallStatus::Error);
|
||||
}
|
||||
|
||||
/// Test invalid status deserialization
|
||||
#[test]
|
||||
fn test_invalid_status_deserialization() {
|
||||
let result: Result<ToolCallStatus, _> = serde_json::from_str(r#""invalid""#);
|
||||
assert!(result.is_err(), "Should fail with invalid status");
|
||||
|
||||
let result: Result<ToolCallStatus, _> = serde_json::from_str(r#""PENDING""#);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should fail with uppercase (not snake_case)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test ToolCallStatus roundtrip
|
||||
#[test]
|
||||
fn test_status_roundtrip() {
|
||||
let statuses = [
|
||||
ToolCallStatus::Pending,
|
||||
ToolCallStatus::Running,
|
||||
ToolCallStatus::Completed,
|
||||
ToolCallStatus::Error,
|
||||
];
|
||||
|
||||
for status in statuses {
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
let deserialized: ToolCallStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(status, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test ToolCallStatus equality and copy
|
||||
#[test]
|
||||
fn test_status_equality_and_copy() {
|
||||
let status1 = ToolCallStatus::Pending;
|
||||
let status2 = status1; // Copy
|
||||
assert_eq!(status1, status2);
|
||||
|
||||
let status3 = ToolCallStatus::Running;
|
||||
assert_ne!(status1, status3);
|
||||
}
|
||||
|
||||
// ===== ToolCall Minimal Tests =====
|
||||
|
||||
/// Test ToolCall with minimal required fields
|
||||
#[test]
|
||||
fn test_tool_call_minimal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_min".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
|
||||
// Required fields present
|
||||
assert!(json.contains(r#""id":"call_min""#));
|
||||
assert!(json.contains(r#""tool_name":"test""#));
|
||||
assert!(json.contains(r#""status":"pending""#));
|
||||
assert!(json.contains(r#""content":[]"#));
|
||||
|
||||
// Optional fields not present
|
||||
assert!(!json.contains(r#""raw_input""#));
|
||||
assert!(!json.contains(r#""raw_output""#));
|
||||
assert!(!json.contains(r#""title""#));
|
||||
assert!(!json.contains(r#""error""#));
|
||||
assert!(!json.contains(r#""metadata""#));
|
||||
}
|
||||
|
||||
/// Test ToolCall with all fields populated
|
||||
#[test]
|
||||
fn test_tool_call_maximal() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_max".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "Output".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"command": "ls"})),
|
||||
raw_output: Some(json!({"exit_code": 0})),
|
||||
title: Some("List files".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 123})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
|
||||
// All fields present
|
||||
assert!(json.contains(r#""id":"call_max""#));
|
||||
assert!(json.contains(r#""tool_name":"bash""#));
|
||||
assert!(json.contains(r#""status":"completed""#));
|
||||
assert!(json.contains(r#""content""#));
|
||||
assert!(json.contains(r#""raw_input""#));
|
||||
assert!(json.contains(r#""raw_output""#));
|
||||
assert!(json.contains(r#""title":"List files""#));
|
||||
assert!(json.contains(r#""metadata""#));
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Empty Strings =====
|
||||
|
||||
/// Test empty tool_name
|
||||
#[test]
|
||||
fn test_empty_tool_name() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_empty_name".to_string(),
|
||||
tool_name: String::new(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""tool_name":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.tool_name, "");
|
||||
}
|
||||
|
||||
/// Test empty id
|
||||
#[test]
|
||||
fn test_empty_id() {
|
||||
let tool_call = ToolCall {
|
||||
id: String::new(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""id":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.id, "");
|
||||
}
|
||||
|
||||
/// Test empty title (Some(""))
|
||||
#[test]
|
||||
fn test_empty_title() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_empty_title".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: Some(String::new()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""title":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.title, Some(String::new()));
|
||||
}
|
||||
|
||||
/// Test empty error message (Some(""))
|
||||
#[test]
|
||||
fn test_empty_error_message() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_empty_error".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some(String::new()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""error":"""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.error, Some(String::new()));
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Large Data =====
|
||||
|
||||
/// Test very long error message
|
||||
#[test]
|
||||
fn test_long_error_message() {
|
||||
let long_error = "Error: ".to_string() + &"x".repeat(10_000);
|
||||
let tool_call = ToolCall {
|
||||
id: "call_long_error".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some(long_error.clone()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.error.unwrap().len(), long_error.len());
|
||||
}
|
||||
|
||||
/// Test large metadata
|
||||
#[test]
|
||||
fn test_large_metadata() {
|
||||
let large_meta = json!({
|
||||
"key1": "value".repeat(1000),
|
||||
"key2": [1, 2, 3, 4, 5],
|
||||
"nested": {
|
||||
"deep": {
|
||||
"value": "test"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: "call_large_meta".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: Some(large_meta.clone()),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.metadata, Some(large_meta));
|
||||
}
|
||||
|
||||
/// Test many content blocks
|
||||
#[test]
|
||||
fn test_many_content_blocks() {
|
||||
let mut content = vec![];
|
||||
for i in 0..100 {
|
||||
content.push(ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: format!("Line {}", i),
|
||||
}));
|
||||
}
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: "call_many_blocks".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: content.clone(),
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.content.len(), 100);
|
||||
assert_eq!(deserialized.content, content);
|
||||
}
|
||||
|
||||
// ===== Edge Cases: Special Characters =====
|
||||
|
||||
/// Test special characters in tool_name
|
||||
#[test]
|
||||
fn test_special_chars_in_tool_name() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_special".to_string(),
|
||||
tool_name: "bash::execute!@#$%^&*()".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.tool_name, "bash::execute!@#$%^&*()");
|
||||
}
|
||||
|
||||
/// Test unicode in error message
|
||||
#[test]
|
||||
fn test_unicode_in_error() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_unicode".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some("错误: 文件不存在 🚫".to_string()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.error.unwrap(), "错误: 文件不存在 🚫");
|
||||
}
|
||||
|
||||
// ===== Default Content Field =====
|
||||
|
||||
/// Test that content defaults to empty vec when not in JSON
|
||||
#[test]
|
||||
fn test_content_default() {
|
||||
let json = r#"{
|
||||
"id": "call_default",
|
||||
"tool_name": "test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let tool_call: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(tool_call.content, vec![]);
|
||||
}
|
||||
|
||||
/// Test that explicit empty content works
|
||||
#[test]
|
||||
fn test_explicit_empty_content() {
|
||||
let json = r#"{
|
||||
"id": "call_explicit",
|
||||
"tool_name": "test",
|
||||
"status": "pending",
|
||||
"content": []
|
||||
}"#;
|
||||
|
||||
let tool_call: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(tool_call.content, vec![]);
|
||||
}
|
||||
|
||||
// ===== Error Cases =====
|
||||
|
||||
/// Test missing required field (id)
|
||||
#[test]
|
||||
fn test_missing_id() {
|
||||
let json = r#"{
|
||||
"tool_name": "test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without id");
|
||||
}
|
||||
|
||||
/// Test missing required field (tool_name)
|
||||
#[test]
|
||||
fn test_missing_tool_name() {
|
||||
let json = r#"{
|
||||
"id": "call_test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without tool_name");
|
||||
}
|
||||
|
||||
/// Test missing required field (status)
|
||||
#[test]
|
||||
fn test_missing_status() {
|
||||
let json = r#"{
|
||||
"id": "call_test",
|
||||
"tool_name": "test"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail without status");
|
||||
}
|
||||
|
||||
/// Test null values for required fields
|
||||
#[test]
|
||||
fn test_null_required_fields() {
|
||||
let json = r#"{
|
||||
"id": null,
|
||||
"tool_name": "test",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: Result<ToolCall, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "Should fail with null id");
|
||||
}
|
||||
|
||||
/// Test null values for optional fields (should be None)
|
||||
#[test]
|
||||
fn test_null_optional_fields() {
|
||||
let json = r#"{
|
||||
"id": "call_null_opts",
|
||||
"tool_name": "test",
|
||||
"status": "pending",
|
||||
"raw_input": null,
|
||||
"raw_output": null,
|
||||
"title": null,
|
||||
"error": null,
|
||||
"metadata": null
|
||||
}"#;
|
||||
|
||||
let tool_call: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert!(tool_call.raw_input.is_none());
|
||||
assert!(tool_call.raw_output.is_none());
|
||||
assert!(tool_call.title.is_none());
|
||||
assert!(tool_call.error.is_none());
|
||||
assert!(tool_call.metadata.is_none());
|
||||
}
|
||||
|
||||
// ===== Status-Specific Tests =====
|
||||
|
||||
/// Test Error status with error message
|
||||
#[test]
|
||||
fn test_error_status_with_message() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_error".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: Some("Something went wrong".to_string()),
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
assert!(json.contains(r#""status":"error""#));
|
||||
assert!(json.contains(r#""error":"Something went wrong""#));
|
||||
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.status, ToolCallStatus::Error);
|
||||
assert_eq!(
|
||||
deserialized.error,
|
||||
Some("Something went wrong".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Test Completed status with output
|
||||
#[test]
|
||||
fn test_completed_status_with_output() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_completed".to_string(),
|
||||
tool_name: "read".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![ToolCallContent::from_content_block(ContentBlock::Text {
|
||||
text: "File contents".to_string(),
|
||||
})],
|
||||
raw_input: Some(json!({"path": "/tmp/test.txt"})),
|
||||
raw_output: Some(json!({"bytes_read": 1024})),
|
||||
title: Some("Read file".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({"duration_ms": 42})),
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.status, ToolCallStatus::Completed);
|
||||
assert!(deserialized.raw_output.is_some());
|
||||
assert!(deserialized.error.is_none());
|
||||
}
|
||||
|
||||
// ===== Roundtrip Tests =====
|
||||
|
||||
/// Test roundtrip for all status variants
|
||||
#[test]
|
||||
fn test_roundtrip_all_statuses() {
|
||||
let statuses = [
|
||||
ToolCallStatus::Pending,
|
||||
ToolCallStatus::Running,
|
||||
ToolCallStatus::Completed,
|
||||
ToolCallStatus::Error,
|
||||
];
|
||||
|
||||
for status in statuses {
|
||||
let tool_call = ToolCall {
|
||||
id: format!("call_{:?}", status),
|
||||
tool_name: "test".to_string(),
|
||||
status,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&tool_call).unwrap();
|
||||
let deserialized: ToolCall = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(tool_call, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Clone and Debug =====
|
||||
|
||||
/// Test ToolCall clone
|
||||
#[test]
|
||||
fn test_tool_call_clone() {
|
||||
let original = ToolCall {
|
||||
id: "call_clone".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let cloned = original.clone();
|
||||
assert_eq!(original, cloned);
|
||||
}
|
||||
|
||||
/// Test ToolCall debug formatting
|
||||
#[test]
|
||||
fn test_tool_call_debug() {
|
||||
let tool_call = ToolCall {
|
||||
id: "call_debug".to_string(),
|
||||
tool_name: "test".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
origin: None,
|
||||
};
|
||||
|
||||
let debug_str = format!("{:?}", tool_call);
|
||||
assert!(debug_str.contains("ToolCall"));
|
||||
assert!(debug_str.contains("call_debug"));
|
||||
}
|
||||
Reference in New Issue
Block a user