1066 lines
35 KiB
Rust
1066 lines
35 KiB
Rust
/// Comprehensive integration tests for OpenCode event → SessionUpdate translation
|
|
/// Tests realistic event sequences with proper ordering, metadata, and tool lifecycle tracking
|
|
use dirigent_protocol::adapters::{OpenCodeAdapter, TranslationError};
|
|
use dirigent_protocol::types::{ContentBlock, SessionUpdate, ToolCallStatus};
|
|
use opencode_client::types as oc;
|
|
use serde_json::json;
|
|
|
|
// ===== Helper Functions =====
|
|
|
|
/// Create a text part for testing
|
|
fn create_text_part(
|
|
session_id: &str,
|
|
message_id: &str,
|
|
part_id: &str,
|
|
text: &str,
|
|
) -> oc::Part {
|
|
oc::Part::Text(oc::TextPart {
|
|
id: part_id.to_string(),
|
|
session_id: session_id.to_string(),
|
|
message_id: message_id.to_string(),
|
|
text: text.to_string(),
|
|
synthetic: None,
|
|
time: Some(oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
}),
|
|
})
|
|
}
|
|
|
|
/// Create a reasoning part for testing
|
|
fn create_reasoning_part(
|
|
session_id: &str,
|
|
message_id: &str,
|
|
part_id: &str,
|
|
text: &str,
|
|
) -> oc::Part {
|
|
oc::Part::Reasoning(oc::ReasoningPart {
|
|
id: part_id.to_string(),
|
|
session_id: session_id.to_string(),
|
|
message_id: message_id.to_string(),
|
|
text: text.to_string(),
|
|
time: Some(oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
}),
|
|
})
|
|
}
|
|
|
|
/// Create a tool part with given state
|
|
fn create_tool_part(
|
|
session_id: &str,
|
|
message_id: &str,
|
|
part_id: &str,
|
|
tool: &str,
|
|
state: oc::ToolState,
|
|
) -> oc::Part {
|
|
oc::Part::Tool(oc::ToolPart {
|
|
id: part_id.to_string(),
|
|
session_id: session_id.to_string(),
|
|
message_id: message_id.to_string(),
|
|
call_id: format!("call_{}", part_id),
|
|
tool: tool.to_string(),
|
|
state,
|
|
metadata: None,
|
|
})
|
|
}
|
|
|
|
/// Create a file part for testing
|
|
fn create_file_part(
|
|
session_id: &str,
|
|
message_id: &str,
|
|
part_id: &str,
|
|
filename: &str,
|
|
url: &str,
|
|
mime: &str,
|
|
) -> oc::Part {
|
|
oc::Part::File(oc::FilePart {
|
|
id: part_id.to_string(),
|
|
session_id: session_id.to_string(),
|
|
message_id: message_id.to_string(),
|
|
mime: mime.to_string(),
|
|
filename: Some(filename.to_string()),
|
|
url: url.to_string(),
|
|
source: None,
|
|
})
|
|
}
|
|
|
|
/// Create a MessagePartUpdated event
|
|
fn create_part_event(part: oc::Part, delta: Option<String>) -> oc::Event {
|
|
oc::Event::MessagePartUpdated {
|
|
properties: oc::MessagePartEventInfo { part, delta },
|
|
}
|
|
}
|
|
|
|
// ===== Test Case 1: Text Streaming =====
|
|
|
|
/// Test text streaming produces AgentMessageChunk sequence with proper metadata
|
|
#[test]
|
|
fn test_text_streaming_to_agent_message_chunks() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_text";
|
|
let message_id = "msg_text";
|
|
|
|
// Simulate streaming text in multiple chunks
|
|
let chunks = vec![
|
|
("part_1", "Hello", "Hello"),
|
|
("part_2", "Hello world", " world"),
|
|
("part_3", "Hello world!", "!"),
|
|
];
|
|
|
|
let mut updates = vec![];
|
|
for (part_id, full_text, delta) in chunks {
|
|
let part = create_text_part(session_id, message_id, part_id, full_text);
|
|
let event = create_part_event(part, Some(delta.to_string()));
|
|
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_ok(), "Translation should succeed");
|
|
|
|
let update = result.unwrap();
|
|
assert!(update.is_some(), "Should return an update");
|
|
updates.push(update.unwrap());
|
|
}
|
|
|
|
// Verify all updates are AgentMessageChunk
|
|
assert_eq!(updates.len(), 3);
|
|
|
|
for (i, update) in updates.iter().enumerate() {
|
|
match update {
|
|
SessionUpdate::AgentMessageChunk {
|
|
message_id: msg_id,
|
|
content,
|
|
_meta,
|
|
} => {
|
|
assert_eq!(msg_id, message_id);
|
|
|
|
// Verify content is Text
|
|
match content {
|
|
ContentBlock::Text { text } => {
|
|
// Each chunk should contain the full text up to that point
|
|
assert!(!text.is_empty());
|
|
}
|
|
_ => panic!("Expected Text content"),
|
|
}
|
|
|
|
// Verify metadata is present
|
|
assert!(_meta.is_some(), "Metadata should be present on chunk {}", i);
|
|
let meta = _meta.as_ref().unwrap();
|
|
assert!(meta.provider.is_some(), "Provider metadata should be present");
|
|
|
|
let provider = meta.provider.as_ref().unwrap();
|
|
assert_eq!(provider.name, "opencode");
|
|
assert!(provider.original_ids.is_some());
|
|
|
|
let ids = provider.original_ids.as_ref().unwrap();
|
|
assert_eq!(ids.get("session_id").unwrap(), session_id);
|
|
assert_eq!(ids.get("message_id").unwrap(), message_id);
|
|
assert!(ids.contains_key("part_id"));
|
|
}
|
|
_ => panic!("Expected AgentMessageChunk at index {}", i),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test that chunks are created in order
|
|
#[test]
|
|
fn test_text_chunks_preserve_order() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_order";
|
|
let message_id = "msg_order";
|
|
|
|
let expected_order = vec!["First", "Second", "Third", "Fourth"];
|
|
|
|
for (i, text) in expected_order.iter().enumerate() {
|
|
let part_id = format!("part_{}", i);
|
|
let part = create_text_part(session_id, message_id, &part_id, text);
|
|
let event = create_part_event(part, Some(text.to_string()));
|
|
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_ok());
|
|
|
|
let update = result.unwrap().unwrap();
|
|
match update {
|
|
SessionUpdate::AgentMessageChunk { content, .. } => {
|
|
if let ContentBlock::Text { text: chunk_text } = content {
|
|
assert_eq!(chunk_text, *text);
|
|
} else {
|
|
panic!("Expected Text content");
|
|
}
|
|
}
|
|
_ => panic!("Expected AgentMessageChunk"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Test Case 2: Reasoning Streaming =====
|
|
|
|
/// Test reasoning streaming produces AgentThoughtChunk sequence with metadata
|
|
#[test]
|
|
fn test_reasoning_streaming_to_agent_thought_chunks() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_reason";
|
|
let message_id = "msg_reason";
|
|
|
|
// Simulate streaming reasoning in chunks
|
|
// Each chunk: (part_id, full_text, delta, expected_delta)
|
|
let chunks = vec![
|
|
("reason_1", "I need to", "I need to"),
|
|
("reason_2", "I need to analyze", " analyze"),
|
|
("reason_3", "I need to analyze the problem", " the problem"),
|
|
];
|
|
|
|
let mut updates = vec![];
|
|
for (part_id, full_text, delta) in chunks.iter() {
|
|
let part = create_reasoning_part(session_id, message_id, part_id, full_text);
|
|
let event = create_part_event(part, Some(delta.to_string()));
|
|
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_ok());
|
|
|
|
let update = result.unwrap();
|
|
assert!(update.is_some());
|
|
updates.push((update.unwrap(), delta));
|
|
}
|
|
|
|
// Verify all are AgentThoughtChunk with metadata
|
|
assert_eq!(updates.len(), 3);
|
|
|
|
for (i, (update, expected_delta)) in updates.iter().enumerate() {
|
|
match update {
|
|
SessionUpdate::AgentThoughtChunk {
|
|
message_id: msg_id,
|
|
content,
|
|
_meta,
|
|
} => {
|
|
assert_eq!(msg_id, message_id);
|
|
|
|
// Verify content contains the delta, not the full accumulated text
|
|
match content {
|
|
ContentBlock::Text { text } => {
|
|
assert_eq!(text, *expected_delta, "Chunk {} should contain delta", i);
|
|
}
|
|
_ => panic!("Expected Text content"),
|
|
}
|
|
|
|
// Verify metadata
|
|
assert!(_meta.is_some(), "Metadata should be present on chunk {}", i);
|
|
let meta = _meta.as_ref().unwrap();
|
|
assert!(meta.provider.is_some());
|
|
|
|
let provider = meta.provider.as_ref().unwrap();
|
|
assert_eq!(provider.name, "opencode");
|
|
assert!(provider.original_ids.is_some());
|
|
|
|
let ids = provider.original_ids.as_ref().unwrap();
|
|
assert_eq!(ids.get("session_id").unwrap(), session_id);
|
|
assert_eq!(ids.get("message_id").unwrap(), message_id);
|
|
}
|
|
_ => panic!("Expected AgentThoughtChunk at index {}", i),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Test Case 3: Tool Lifecycle (Pending → Running → Completed) =====
|
|
|
|
/// Test complete tool lifecycle produces ToolCall followed by ToolCallUpdate events
|
|
#[test]
|
|
fn test_tool_lifecycle_pending_to_completed() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_tool";
|
|
let message_id = "msg_tool";
|
|
let part_id = "tool_1";
|
|
let tool_name = "bash";
|
|
|
|
let input = json!({"command": "ls -la"});
|
|
let output = "file1.txt\nfile2.txt";
|
|
|
|
// Step 1: Pending state → ToolCall
|
|
let pending_part = create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
part_id,
|
|
tool_name,
|
|
oc::ToolState::Pending,
|
|
);
|
|
let pending_event = create_part_event(pending_part, None);
|
|
|
|
let result = adapter.translate_to_session_update(pending_event);
|
|
assert!(result.is_ok());
|
|
let update = result.unwrap().unwrap();
|
|
|
|
match &update {
|
|
SessionUpdate::ToolCall {
|
|
message_id: msg_id,
|
|
tool_call,
|
|
_meta,
|
|
} => {
|
|
assert_eq!(msg_id, message_id);
|
|
assert_eq!(tool_call.id, part_id);
|
|
assert_eq!(tool_call.tool_name, tool_name);
|
|
assert_eq!(tool_call.status, ToolCallStatus::Pending);
|
|
assert!(tool_call.raw_input.is_none());
|
|
assert!(tool_call.raw_output.is_none());
|
|
assert!(tool_call.error.is_none());
|
|
|
|
// Verify metadata
|
|
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 ToolCall for pending state"),
|
|
}
|
|
|
|
// Step 2: Running state → ToolCallUpdate
|
|
let running_part = create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
part_id,
|
|
tool_name,
|
|
oc::ToolState::Running {
|
|
input: input.clone(),
|
|
title: None,
|
|
metadata: None,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
},
|
|
},
|
|
);
|
|
let running_event = create_part_event(running_part, Some("running".to_string()));
|
|
|
|
let result = adapter.translate_to_session_update(running_event);
|
|
assert!(result.is_ok());
|
|
let update = result.unwrap().unwrap();
|
|
|
|
match &update {
|
|
SessionUpdate::ToolCallUpdate {
|
|
message_id: msg_id,
|
|
tool_call_id,
|
|
tool_call,
|
|
_meta,
|
|
} => {
|
|
assert_eq!(msg_id, message_id);
|
|
assert_eq!(tool_call_id, part_id);
|
|
assert_eq!(tool_call.id, part_id);
|
|
assert_eq!(tool_call.tool_name, tool_name);
|
|
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
|
assert_eq!(tool_call.raw_input, Some(input.clone()));
|
|
assert!(tool_call.raw_output.is_none());
|
|
assert!(tool_call.error.is_none());
|
|
|
|
// Verify metadata throughout lifecycle
|
|
assert!(_meta.is_some());
|
|
let meta = _meta.as_ref().unwrap();
|
|
assert!(meta.provider.is_some());
|
|
}
|
|
_ => panic!("Expected ToolCallUpdate for running state"),
|
|
}
|
|
|
|
// Step 3: Completed state → ToolCallUpdate
|
|
let completed_part = create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
part_id,
|
|
tool_name,
|
|
oc::ToolState::Completed {
|
|
input: input.clone(),
|
|
output: output.to_string(),
|
|
title: "bash command".to_string(),
|
|
metadata: serde_json::Value::Null,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: Some(2000),
|
|
},
|
|
attachments: None,
|
|
},
|
|
);
|
|
let completed_event = create_part_event(completed_part, Some(output.to_string()));
|
|
|
|
let result = adapter.translate_to_session_update(completed_event);
|
|
assert!(result.is_ok());
|
|
let update = result.unwrap().unwrap();
|
|
|
|
match &update {
|
|
SessionUpdate::ToolCallUpdate {
|
|
message_id: msg_id,
|
|
tool_call_id,
|
|
tool_call,
|
|
_meta,
|
|
} => {
|
|
assert_eq!(msg_id, message_id);
|
|
assert_eq!(tool_call_id, part_id);
|
|
assert_eq!(tool_call.id, part_id);
|
|
assert_eq!(tool_call.tool_name, tool_name);
|
|
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
|
assert_eq!(tool_call.raw_input, Some(input));
|
|
assert_eq!(
|
|
tool_call.raw_output,
|
|
Some(serde_json::Value::String(output.to_string()))
|
|
);
|
|
assert!(tool_call.error.is_none());
|
|
|
|
// Verify metadata in final state
|
|
assert!(_meta.is_some());
|
|
}
|
|
_ => panic!("Expected ToolCallUpdate for completed state"),
|
|
}
|
|
}
|
|
|
|
/// Test status transitions are properly tracked
|
|
#[test]
|
|
fn test_tool_status_transitions() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_status";
|
|
let message_id = "msg_status";
|
|
let part_id = "tool_status";
|
|
|
|
let expected_statuses = vec![
|
|
(oc::ToolState::Pending, ToolCallStatus::Pending),
|
|
(
|
|
oc::ToolState::Running {
|
|
input: json!({}),
|
|
title: None,
|
|
metadata: None,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
},
|
|
},
|
|
ToolCallStatus::Running,
|
|
),
|
|
(
|
|
oc::ToolState::Completed {
|
|
input: json!({}),
|
|
output: "done".to_string(),
|
|
title: "".to_string(),
|
|
metadata: serde_json::Value::Null,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: Some(2000),
|
|
},
|
|
attachments: None,
|
|
},
|
|
ToolCallStatus::Completed,
|
|
),
|
|
];
|
|
|
|
for (i, (oc_state, expected_status)) in expected_statuses.into_iter().enumerate() {
|
|
let part = create_tool_part(session_id, message_id, part_id, "test", oc_state);
|
|
let event = create_part_event(part, Some("delta".to_string()));
|
|
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_ok());
|
|
let update = result.unwrap().unwrap();
|
|
|
|
let actual_status = match &update {
|
|
SessionUpdate::ToolCall { tool_call, .. } => &tool_call.status,
|
|
SessionUpdate::ToolCallUpdate { tool_call, .. } => &tool_call.status,
|
|
_ => panic!("Expected tool update at step {}", i),
|
|
};
|
|
|
|
assert_eq!(actual_status, &expected_status, "Status mismatch at step {}", i);
|
|
}
|
|
}
|
|
|
|
// ===== Test Case 4: Tool Error =====
|
|
|
|
/// Test tool error produces ToolCallUpdate with Error status and error field
|
|
#[test]
|
|
fn test_tool_error_to_tool_call_update() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_error";
|
|
let message_id = "msg_error";
|
|
let part_id = "tool_error";
|
|
let error_msg = "Command not found: invalid_cmd";
|
|
let input = json!({"command": "invalid_cmd"});
|
|
|
|
// First, create the tool with pending state
|
|
let pending_part = create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
part_id,
|
|
"bash",
|
|
oc::ToolState::Pending,
|
|
);
|
|
let pending_event = create_part_event(pending_part, None);
|
|
let _ = adapter.translate_to_session_update(pending_event);
|
|
|
|
// Now send error state
|
|
let error_part = create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
part_id,
|
|
"bash",
|
|
oc::ToolState::Error {
|
|
input: input.clone(),
|
|
error: error_msg.to_string(),
|
|
metadata: None,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: Some(2000),
|
|
},
|
|
},
|
|
);
|
|
let error_event = create_part_event(error_part, Some(error_msg.to_string()));
|
|
|
|
let result = adapter.translate_to_session_update(error_event);
|
|
assert!(result.is_ok());
|
|
let update = result.unwrap().unwrap();
|
|
|
|
match &update {
|
|
SessionUpdate::ToolCallUpdate {
|
|
message_id: msg_id,
|
|
tool_call_id,
|
|
tool_call,
|
|
_meta,
|
|
} => {
|
|
assert_eq!(msg_id, message_id);
|
|
assert_eq!(tool_call_id, part_id);
|
|
assert_eq!(tool_call.status, ToolCallStatus::Error);
|
|
assert_eq!(tool_call.raw_input, Some(input));
|
|
assert!(tool_call.raw_output.is_none());
|
|
assert_eq!(tool_call.error, Some(error_msg.to_string()));
|
|
|
|
// Verify error message is captured
|
|
let err = tool_call.error.as_ref().unwrap();
|
|
assert!(err.contains("Command not found"));
|
|
|
|
// Verify metadata
|
|
assert!(_meta.is_some());
|
|
}
|
|
_ => panic!("Expected ToolCallUpdate for error state"),
|
|
}
|
|
}
|
|
|
|
// ===== Test Case 5: File Reference =====
|
|
|
|
/// Test file part produces ResourceLink in AgentMessageChunk
|
|
#[test]
|
|
fn test_file_reference_to_resource_link() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_file";
|
|
let message_id = "msg_file";
|
|
let part_id = "file_1";
|
|
let filename = "test_document.pdf";
|
|
let uri = "file:///home/user/documents/test_document.pdf";
|
|
let mime_type = "application/pdf";
|
|
|
|
let part = create_file_part(session_id, message_id, part_id, filename, uri, mime_type);
|
|
let event = create_part_event(part, None);
|
|
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_ok());
|
|
let update = result.unwrap().unwrap();
|
|
|
|
match &update {
|
|
SessionUpdate::AgentMessageChunk {
|
|
message_id: msg_id,
|
|
content,
|
|
_meta,
|
|
} => {
|
|
assert_eq!(msg_id, message_id);
|
|
|
|
// Verify content is ResourceLink
|
|
match content {
|
|
ContentBlock::ResourceLink {
|
|
uri: content_uri,
|
|
name,
|
|
mime_type: content_mime,
|
|
} => {
|
|
assert_eq!(content_uri, uri);
|
|
assert_eq!(name.as_ref().unwrap(), filename);
|
|
assert_eq!(content_mime.as_ref().unwrap(), mime_type);
|
|
}
|
|
_ => panic!("Expected ResourceLink content"),
|
|
}
|
|
|
|
// Verify metadata
|
|
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");
|
|
assert!(provider.original_ids.is_some());
|
|
let ids = provider.original_ids.as_ref().unwrap();
|
|
assert_eq!(ids.get("part_id").unwrap(), part_id);
|
|
}
|
|
_ => panic!("Expected AgentMessageChunk with ResourceLink"),
|
|
}
|
|
}
|
|
|
|
/// Test file with different mime types
|
|
#[test]
|
|
fn test_file_references_various_mime_types() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_files";
|
|
let message_id = "msg_files";
|
|
|
|
let test_cases = vec![
|
|
("file_txt", "data.txt", "file:///data.txt", "text/plain"),
|
|
("file_json", "config.json", "file:///config.json", "application/json"),
|
|
("file_img", "screenshot.png", "file:///screenshot.png", "image/png"),
|
|
];
|
|
|
|
for (part_id, filename, uri, mime_type) in test_cases {
|
|
let part = create_file_part(session_id, message_id, part_id, filename, uri, mime_type);
|
|
let event = create_part_event(part, None);
|
|
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_ok());
|
|
let update = result.unwrap().unwrap();
|
|
|
|
match update {
|
|
SessionUpdate::AgentMessageChunk { content, .. } => {
|
|
if let ContentBlock::ResourceLink {
|
|
uri: content_uri,
|
|
name,
|
|
mime_type: content_mime,
|
|
} = content
|
|
{
|
|
assert_eq!(content_uri, uri);
|
|
assert_eq!(name.as_ref().unwrap(), filename);
|
|
assert_eq!(content_mime.as_ref().unwrap(), mime_type);
|
|
} else {
|
|
panic!("Expected ResourceLink for {}", filename);
|
|
}
|
|
}
|
|
_ => panic!("Expected AgentMessageChunk for {}", filename),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Test Case 6: Interleaved Updates Preserve Order =====
|
|
|
|
/// Test mix of text, reasoning, and tool updates maintain correct order
|
|
#[test]
|
|
fn test_interleaved_updates_preserve_order() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_interleaved";
|
|
let message_id = "msg_interleaved";
|
|
|
|
// Create a realistic sequence of interleaved events
|
|
let events = vec![
|
|
// Text chunk 1
|
|
(
|
|
"text",
|
|
create_part_event(
|
|
create_text_part(session_id, message_id, "part_1", "Let me think"),
|
|
Some("Let me think".to_string()),
|
|
),
|
|
),
|
|
// Reasoning chunk 1
|
|
(
|
|
"thought",
|
|
create_part_event(
|
|
create_reasoning_part(session_id, message_id, "reason_1", "Analyzing the task"),
|
|
Some("Analyzing the task".to_string()),
|
|
),
|
|
),
|
|
// Tool pending
|
|
(
|
|
"tool_pending",
|
|
create_part_event(
|
|
create_tool_part(session_id, message_id, "tool_1", "bash", oc::ToolState::Pending),
|
|
None,
|
|
),
|
|
),
|
|
// Text chunk 2
|
|
(
|
|
"text",
|
|
create_part_event(
|
|
create_text_part(session_id, message_id, "part_2", "Running command"),
|
|
Some("Running command".to_string()),
|
|
),
|
|
),
|
|
// Tool running
|
|
(
|
|
"tool_running",
|
|
create_part_event(
|
|
create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
"tool_1",
|
|
"bash",
|
|
oc::ToolState::Running {
|
|
input: json!({"cmd": "ls"}),
|
|
title: None,
|
|
metadata: None,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
},
|
|
},
|
|
),
|
|
Some("running".to_string()),
|
|
),
|
|
),
|
|
// Reasoning chunk 2
|
|
(
|
|
"thought",
|
|
create_part_event(
|
|
create_reasoning_part(session_id, message_id, "reason_2", "Command is executing"),
|
|
Some("Command is executing".to_string()),
|
|
),
|
|
),
|
|
// Tool completed
|
|
(
|
|
"tool_completed",
|
|
create_part_event(
|
|
create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
"tool_1",
|
|
"bash",
|
|
oc::ToolState::Completed {
|
|
input: json!({"cmd": "ls"}),
|
|
output: "file1.txt".to_string(),
|
|
title: "".to_string(),
|
|
metadata: serde_json::Value::Null,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: Some(2000),
|
|
},
|
|
attachments: None,
|
|
},
|
|
),
|
|
Some("file1.txt".to_string()),
|
|
),
|
|
),
|
|
// Text chunk 3
|
|
(
|
|
"text",
|
|
create_part_event(
|
|
create_text_part(session_id, message_id, "part_3", "Done!"),
|
|
Some("Done!".to_string()),
|
|
),
|
|
),
|
|
];
|
|
|
|
let expected_types = vec![
|
|
"text",
|
|
"thought",
|
|
"tool_pending",
|
|
"text",
|
|
"tool_running",
|
|
"thought",
|
|
"tool_completed",
|
|
"text",
|
|
];
|
|
|
|
let mut updates = vec![];
|
|
for (i, (_, event)) in events.into_iter().enumerate() {
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_ok(), "Event {} should translate successfully", i);
|
|
|
|
let update = result.unwrap();
|
|
assert!(update.is_some(), "Event {} should produce an update", i);
|
|
updates.push(update.unwrap());
|
|
}
|
|
|
|
// Verify order is preserved
|
|
assert_eq!(updates.len(), expected_types.len());
|
|
|
|
for (i, (update, expected_type)) in updates.iter().zip(expected_types.iter()).enumerate() {
|
|
match (update, *expected_type) {
|
|
(SessionUpdate::AgentMessageChunk { .. }, "text") => {}
|
|
(SessionUpdate::AgentThoughtChunk { .. }, "thought") => {}
|
|
(SessionUpdate::ToolCall { .. }, "tool_pending") => {}
|
|
(SessionUpdate::ToolCallUpdate { tool_call, .. }, "tool_running") => {
|
|
assert_eq!(tool_call.status, ToolCallStatus::Running);
|
|
}
|
|
(SessionUpdate::ToolCallUpdate { tool_call, .. }, "tool_completed") => {
|
|
assert_eq!(tool_call.status, ToolCallStatus::Completed);
|
|
}
|
|
_ => panic!(
|
|
"Type mismatch at position {}: expected {}, got {:?}",
|
|
i, expected_type, update
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Test correct variant for each update type in sequence
|
|
#[test]
|
|
fn test_correct_variant_for_each_type() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_variants";
|
|
let message_id = "msg_variants";
|
|
|
|
// Test text → AgentMessageChunk
|
|
let text_event = create_part_event(
|
|
create_text_part(session_id, message_id, "p1", "text"),
|
|
Some("text".to_string()),
|
|
);
|
|
let result = adapter.translate_to_session_update(text_event).unwrap().unwrap();
|
|
assert!(matches!(result, SessionUpdate::AgentMessageChunk { .. }));
|
|
|
|
// Test reasoning → AgentThoughtChunk
|
|
let reason_event = create_part_event(
|
|
create_reasoning_part(session_id, message_id, "p2", "thinking"),
|
|
Some("thinking".to_string()),
|
|
);
|
|
let result = adapter.translate_to_session_update(reason_event).unwrap().unwrap();
|
|
assert!(matches!(result, SessionUpdate::AgentThoughtChunk { .. }));
|
|
|
|
// Test tool pending → ToolCall
|
|
let tool_pending_event = create_part_event(
|
|
create_tool_part(session_id, message_id, "p3", "test", oc::ToolState::Pending),
|
|
None,
|
|
);
|
|
let result = adapter.translate_to_session_update(tool_pending_event).unwrap().unwrap();
|
|
assert!(matches!(result, SessionUpdate::ToolCall { .. }));
|
|
|
|
// Test tool running → ToolCallUpdate
|
|
let tool_running_event = create_part_event(
|
|
create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
"p3",
|
|
"test",
|
|
oc::ToolState::Running {
|
|
input: json!({}),
|
|
title: None,
|
|
metadata: None,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
},
|
|
},
|
|
),
|
|
Some("running".to_string()),
|
|
);
|
|
let result = adapter.translate_to_session_update(tool_running_event).unwrap().unwrap();
|
|
assert!(matches!(result, SessionUpdate::ToolCallUpdate { .. }));
|
|
|
|
// Test file → AgentMessageChunk with ResourceLink
|
|
let file_event = create_part_event(
|
|
create_file_part(session_id, message_id, "p4", "f.txt", "file:///f.txt", "text/plain"),
|
|
None,
|
|
);
|
|
let result = adapter.translate_to_session_update(file_event).unwrap().unwrap();
|
|
match result {
|
|
SessionUpdate::AgentMessageChunk { content, .. } => {
|
|
assert!(matches!(content, ContentBlock::ResourceLink { .. }));
|
|
}
|
|
_ => panic!("Expected AgentMessageChunk for file"),
|
|
}
|
|
}
|
|
|
|
// ===== Additional Edge Cases =====
|
|
|
|
/// Test duplicate detection for SessionUpdate translation
|
|
#[test]
|
|
fn test_duplicate_part_skipped_in_session_update() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_dup";
|
|
let message_id = "msg_dup";
|
|
let part_id = "part_dup";
|
|
|
|
// First event with delta
|
|
let part1 = create_text_part(session_id, message_id, part_id, "Hello");
|
|
let event1 = create_part_event(part1, Some("Hello".to_string()));
|
|
|
|
let result1 = adapter.translate_to_session_update(event1);
|
|
assert!(result1.is_ok());
|
|
assert!(result1.unwrap().is_some());
|
|
|
|
// Second event without delta (completion marker) - should be skipped
|
|
let part2 = create_text_part(session_id, message_id, part_id, "Hello");
|
|
let event2 = create_part_event(part2, None);
|
|
|
|
let result2 = adapter.translate_to_session_update(event2);
|
|
assert!(result2.is_ok());
|
|
assert!(result2.unwrap().is_none(), "Duplicate without delta should be skipped");
|
|
}
|
|
|
|
/// Test unsupported event types return error
|
|
#[test]
|
|
fn test_unsupported_events_return_error() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
|
|
// SessionCreated is not supported in translate_to_session_update
|
|
let session_event = oc::Event::SessionCreated {
|
|
properties: oc::SessionEventInfo {
|
|
info: oc::Session {
|
|
id: "sess1".to_string(),
|
|
project_id: "proj1".to_string(),
|
|
directory: "/test".to_string(),
|
|
parent_id: None,
|
|
summary: None,
|
|
share: None,
|
|
title: "Test".to_string(),
|
|
version: "1.0".to_string(),
|
|
time: oc::SessionTime {
|
|
created: 1000,
|
|
updated: 1000,
|
|
compacting: None,
|
|
},
|
|
revert: None,
|
|
},
|
|
},
|
|
};
|
|
|
|
let result = adapter.translate_to_session_update(session_event);
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), TranslationError::UnsupportedEvent));
|
|
}
|
|
|
|
/// Test unsupported part types return error
|
|
#[test]
|
|
fn test_unsupported_part_types_return_error() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
|
|
// Snapshot part is not supported
|
|
let snapshot_part = oc::Part::Snapshot(oc::SnapshotPart {
|
|
id: "snap1".to_string(),
|
|
session_id: "sess1".to_string(),
|
|
message_id: "msg1".to_string(),
|
|
snapshot: "snapshot_data".to_string(),
|
|
});
|
|
|
|
let event = create_part_event(snapshot_part, None);
|
|
let result = adapter.translate_to_session_update(event);
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), TranslationError::UnsupportedPartType));
|
|
}
|
|
|
|
/// Test multiple concurrent tool calls tracked independently
|
|
#[test]
|
|
fn test_multiple_concurrent_tools_independent_tracking() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_multi";
|
|
let message_id = "msg_multi";
|
|
|
|
// Tool 1: Pending
|
|
let tool1_pending = create_part_event(
|
|
create_tool_part(session_id, message_id, "tool_1", "bash", oc::ToolState::Pending),
|
|
None,
|
|
);
|
|
let result1 = adapter.translate_to_session_update(tool1_pending).unwrap().unwrap();
|
|
assert!(matches!(result1, SessionUpdate::ToolCall { .. }));
|
|
|
|
// Tool 2: Pending (different tool)
|
|
let tool2_pending = create_part_event(
|
|
create_tool_part(session_id, message_id, "tool_2", "read", oc::ToolState::Pending),
|
|
None,
|
|
);
|
|
let result2 = adapter.translate_to_session_update(tool2_pending).unwrap().unwrap();
|
|
assert!(matches!(result2, SessionUpdate::ToolCall { .. }));
|
|
|
|
// Tool 1: Running → should be ToolCallUpdate
|
|
let tool1_running = create_part_event(
|
|
create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
"tool_1",
|
|
"bash",
|
|
oc::ToolState::Running {
|
|
input: json!({}),
|
|
title: None,
|
|
metadata: None,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
},
|
|
},
|
|
),
|
|
Some("r".to_string()),
|
|
);
|
|
let result3 = adapter.translate_to_session_update(tool1_running).unwrap().unwrap();
|
|
match result3 {
|
|
SessionUpdate::ToolCallUpdate { tool_call_id, .. } => {
|
|
assert_eq!(tool_call_id, "tool_1");
|
|
}
|
|
_ => panic!("Expected ToolCallUpdate for tool_1"),
|
|
}
|
|
|
|
// Tool 2: Running → should also be ToolCallUpdate
|
|
let tool2_running = create_part_event(
|
|
create_tool_part(
|
|
session_id,
|
|
message_id,
|
|
"tool_2",
|
|
"read",
|
|
oc::ToolState::Running {
|
|
input: json!({}),
|
|
title: None,
|
|
metadata: None,
|
|
time: oc::PartTime {
|
|
start: 1000,
|
|
end: None,
|
|
},
|
|
},
|
|
),
|
|
Some("r".to_string()),
|
|
);
|
|
let result4 = adapter.translate_to_session_update(tool2_running).unwrap().unwrap();
|
|
match result4 {
|
|
SessionUpdate::ToolCallUpdate { tool_call_id, .. } => {
|
|
assert_eq!(tool_call_id, "tool_2");
|
|
}
|
|
_ => panic!("Expected ToolCallUpdate for tool_2"),
|
|
}
|
|
}
|
|
|
|
/// Test metadata present in all update types
|
|
#[test]
|
|
fn test_metadata_present_in_all_updates() {
|
|
let adapter = OpenCodeAdapter::new();
|
|
let session_id = "sess_meta";
|
|
let message_id = "msg_meta";
|
|
|
|
// Text
|
|
let text_event = create_part_event(
|
|
create_text_part(session_id, message_id, "p1", "text"),
|
|
Some("text".to_string()),
|
|
);
|
|
let text_update = adapter.translate_to_session_update(text_event).unwrap().unwrap();
|
|
match text_update {
|
|
SessionUpdate::AgentMessageChunk { _meta, .. } => {
|
|
assert!(_meta.is_some(), "Text chunk should have metadata");
|
|
}
|
|
_ => panic!("Expected AgentMessageChunk"),
|
|
}
|
|
|
|
// Reasoning
|
|
let reason_event = create_part_event(
|
|
create_reasoning_part(session_id, message_id, "p2", "reason"),
|
|
Some("reason".to_string()),
|
|
);
|
|
let reason_update = adapter.translate_to_session_update(reason_event).unwrap().unwrap();
|
|
match reason_update {
|
|
SessionUpdate::AgentThoughtChunk { _meta, .. } => {
|
|
assert!(_meta.is_some(), "Thought chunk should have metadata");
|
|
}
|
|
_ => panic!("Expected AgentThoughtChunk"),
|
|
}
|
|
|
|
// Tool
|
|
let tool_event = create_part_event(
|
|
create_tool_part(session_id, message_id, "p3", "bash", oc::ToolState::Pending),
|
|
None,
|
|
);
|
|
let tool_update = adapter.translate_to_session_update(tool_event).unwrap().unwrap();
|
|
match tool_update {
|
|
SessionUpdate::ToolCall { _meta, .. } => {
|
|
assert!(_meta.is_some(), "ToolCall should have metadata");
|
|
}
|
|
_ => panic!("Expected ToolCall"),
|
|
}
|
|
|
|
// File
|
|
let file_event = create_part_event(
|
|
create_file_part(session_id, message_id, "p4", "f.txt", "file:///f.txt", "text/plain"),
|
|
None,
|
|
);
|
|
let file_update = adapter.translate_to_session_update(file_event).unwrap().unwrap();
|
|
match file_update {
|
|
SessionUpdate::AgentMessageChunk { _meta, .. } => {
|
|
assert!(_meta.is_some(), "File chunk should have metadata");
|
|
}
|
|
_ => panic!("Expected AgentMessageChunk"),
|
|
}
|
|
}
|