621 lines
18 KiB
Markdown
621 lines
18 KiB
Markdown
# Migration Guide: Dirigent Protocol 0.1.x → 0.2.0
|
|
|
|
## Overview
|
|
|
|
Version 0.2.0 introduces a **new ACP-style streaming model** while maintaining backward compatibility with most existing code. This guide will help you migrate to the new `SessionUpdate` event system.
|
|
|
|
## Breaking Changes Summary
|
|
|
|
### Removed: Event::MessagePartAdded
|
|
|
|
The `MessagePartAdded` event variant has been **removed** from the `Event` enum. This was the primary breaking change in 0.2.0.
|
|
|
|
**What was removed:**
|
|
```rust
|
|
// This no longer exists in 0.2.0
|
|
Event::MessagePartAdded {
|
|
session_id: String,
|
|
message_id: String,
|
|
part: MessagePart,
|
|
}
|
|
```
|
|
|
|
**Replaced with:**
|
|
```rust
|
|
// New in 0.2.0
|
|
Event::SessionUpdate {
|
|
session_id: String,
|
|
update: SessionUpdate,
|
|
}
|
|
```
|
|
|
|
## Migration Patterns
|
|
|
|
### Pattern 1: Basic Text Streaming
|
|
|
|
#### Before (0.1.x)
|
|
```rust
|
|
use dirigent_protocol::{Event, MessagePart};
|
|
|
|
match event {
|
|
Event::MessagePartAdded { session_id, message_id, part } => {
|
|
match part {
|
|
MessagePart::Text { content, .. } => {
|
|
println!("Text from {}: {}", message_id, content);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
#### After (0.2.0)
|
|
```rust
|
|
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
|
|
|
|
match event {
|
|
Event::SessionUpdate { session_id, update } => {
|
|
match update {
|
|
SessionUpdate::UserMessageChunk { message_id, content, .. } |
|
|
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
|
match content {
|
|
ContentBlock::Text { text } => {
|
|
println!("Text from {}: {}", message_id, text);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
**Key Changes:**
|
|
- Use `SessionUpdate` instead of `MessagePartAdded`
|
|
- Separate `UserMessageChunk` and `AgentMessageChunk` variants
|
|
- `ContentBlock::Text { text }` instead of `MessagePart::Text { content }`
|
|
- Field renamed: `content` → `text`
|
|
|
|
### Pattern 2: Thinking/Reasoning Content
|
|
|
|
#### Before (0.1.x)
|
|
```rust
|
|
match event {
|
|
Event::MessagePartAdded { session_id, message_id, part } => {
|
|
match part {
|
|
MessagePart::Thinking { content, .. } => {
|
|
println!("Agent thinking: {}", content);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
#### After (0.2.0)
|
|
```rust
|
|
match event {
|
|
Event::SessionUpdate { session_id, update } => {
|
|
match update {
|
|
SessionUpdate::AgentThoughtChunk { message_id, content, .. } => {
|
|
match content {
|
|
ContentBlock::Text { text } => {
|
|
println!("Agent thinking: {}", text);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
**Key Changes:**
|
|
- `MessagePart::Thinking` → `SessionUpdate::AgentThoughtChunk`
|
|
- Content wrapped in `ContentBlock::Text`
|
|
|
|
### Pattern 3: Tool Calls
|
|
|
|
#### Before (0.1.x)
|
|
```rust
|
|
match event {
|
|
Event::MessagePartAdded { session_id, message_id, part } => {
|
|
match part {
|
|
MessagePart::Tool {
|
|
tool_name,
|
|
tool_call_id,
|
|
status,
|
|
input,
|
|
output,
|
|
..
|
|
} => {
|
|
println!("Tool {}: {:?}", tool_name, status);
|
|
if let Some(out) = output {
|
|
println!("Output: {}", out);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
#### After (0.2.0)
|
|
```rust
|
|
use dirigent_protocol::ToolCallStatus;
|
|
|
|
match event {
|
|
Event::SessionUpdate { session_id, update } => {
|
|
match update {
|
|
// Initial tool call
|
|
SessionUpdate::ToolCall { message_id, tool_call, .. } => {
|
|
println!("Tool {}: {:?}", tool_call.tool_name, tool_call.status);
|
|
}
|
|
|
|
// Tool call updates (progress, completion, errors)
|
|
SessionUpdate::ToolCallUpdate { message_id, tool_call_id, tool_call, .. } => {
|
|
println!("Tool {} updated: {:?}", tool_call.tool_name, tool_call.status);
|
|
|
|
// Output is now in content blocks
|
|
for content in &tool_call.content {
|
|
match content {
|
|
ContentBlock::Text { text } => {
|
|
println!("Output: {}", text);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Check for errors
|
|
if tool_call.status == ToolCallStatus::Error {
|
|
if let Some(error) = &tool_call.error {
|
|
println!("Error: {}", error);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
**Key Changes:**
|
|
- Tool lifecycle split into `ToolCall` (initial) and `ToolCallUpdate` (updates)
|
|
- Tool output now in `tool_call.content: Vec<ContentBlock>`
|
|
- Structured `ToolCall` type with multiple fields
|
|
- Explicit `ToolCallStatus` enum (Pending/Running/Completed/Error)
|
|
- Error information in dedicated `error` field
|
|
|
|
### Pattern 4: File References
|
|
|
|
#### Before (0.1.x)
|
|
```rust
|
|
match event {
|
|
Event::MessagePartAdded { session_id, message_id, part } => {
|
|
match part {
|
|
MessagePart::File { path, name, mime_type, .. } => {
|
|
println!("File reference: {} ({})", name.unwrap_or_default(), path);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
#### After (0.2.0)
|
|
```rust
|
|
match event {
|
|
Event::SessionUpdate { session_id, update } => {
|
|
match update {
|
|
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
|
match content {
|
|
ContentBlock::ResourceLink { uri, name, mime_type } => {
|
|
println!("Resource: {} ({})",
|
|
name.as_deref().unwrap_or("unnamed"),
|
|
uri
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
**Key Changes:**
|
|
- `MessagePart::File` → `ContentBlock::ResourceLink`
|
|
- Field renamed: `path` → `uri` (more generic)
|
|
- Can appear in any message chunk type
|
|
|
|
## Complete Migration Example
|
|
|
|
Here's a complete before/after example showing a typical event handler:
|
|
|
|
### Before (0.1.x)
|
|
```rust
|
|
use dirigent_protocol::{Event, MessagePart};
|
|
|
|
fn handle_event(event: Event) {
|
|
match event {
|
|
Event::MessagePartAdded { session_id, message_id, part } => {
|
|
match part {
|
|
MessagePart::Text { content, .. } => {
|
|
append_text(&message_id, &content);
|
|
}
|
|
MessagePart::Thinking { content, .. } => {
|
|
show_thinking(&message_id, &content);
|
|
}
|
|
MessagePart::Tool { tool_name, tool_call_id, status, output, .. } => {
|
|
update_tool_display(&tool_call_id, &tool_name, status, output.as_deref());
|
|
}
|
|
MessagePart::File { path, name, .. } => {
|
|
add_file_reference(&message_id, &path, name.as_deref());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Event::MessageStarted { session_id, message_id, .. } => {
|
|
create_message_container(&message_id);
|
|
}
|
|
Event::MessageCompleted { session_id, message_id, .. } => {
|
|
finalize_message(&message_id);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
```
|
|
|
|
### After (0.2.0)
|
|
```rust
|
|
use dirigent_protocol::{Event, SessionUpdate, ContentBlock, ToolCallStatus};
|
|
|
|
fn handle_event(event: Event) {
|
|
match event {
|
|
Event::SessionUpdate { session_id, update } => {
|
|
match update {
|
|
SessionUpdate::UserMessageChunk { message_id, content, .. } |
|
|
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
|
match content {
|
|
ContentBlock::Text { text } => {
|
|
append_text(&message_id, &text);
|
|
}
|
|
ContentBlock::ResourceLink { uri, name, .. } => {
|
|
add_file_reference(&message_id, &uri, name.as_deref());
|
|
}
|
|
}
|
|
}
|
|
|
|
SessionUpdate::AgentThoughtChunk { message_id, content, .. } => {
|
|
if let ContentBlock::Text { text } = content {
|
|
show_thinking(&message_id, &text);
|
|
}
|
|
}
|
|
|
|
SessionUpdate::ToolCall { message_id, tool_call, .. } => {
|
|
create_tool_display(
|
|
&tool_call.id,
|
|
&tool_call.tool_name,
|
|
tool_call.status,
|
|
);
|
|
}
|
|
|
|
SessionUpdate::ToolCallUpdate { tool_call_id, tool_call, .. } => {
|
|
// Extract output text from content blocks
|
|
let output_text = tool_call.content.iter()
|
|
.filter_map(|c| match c {
|
|
ContentBlock::Text { text } => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
|
|
update_tool_display(
|
|
&tool_call_id,
|
|
&tool_call.tool_name,
|
|
tool_call.status,
|
|
if output_text.is_empty() { None } else { Some(&output_text) },
|
|
);
|
|
|
|
// Handle errors
|
|
if tool_call.status == ToolCallStatus::Error {
|
|
if let Some(error) = &tool_call.error {
|
|
show_tool_error(&tool_call_id, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// These events remain unchanged
|
|
Event::MessageStarted { session_id, message_id, .. } => {
|
|
create_message_container(&message_id);
|
|
}
|
|
Event::MessageCompleted { session_id, message_id, .. } => {
|
|
finalize_message(&message_id);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
```
|
|
|
|
## UI State Management Changes
|
|
|
|
### Before: Simple Append Model
|
|
|
|
```rust
|
|
struct MessageState {
|
|
id: String,
|
|
text: String,
|
|
thinking: String,
|
|
tools: Vec<ToolDisplay>,
|
|
}
|
|
|
|
// On MessagePartAdded with Text:
|
|
message.text.push_str(&content);
|
|
```
|
|
|
|
### After: ContentBlock Streaming
|
|
|
|
```rust
|
|
use std::collections::HashMap;
|
|
|
|
struct MessageState {
|
|
id: String,
|
|
content_blocks: Vec<ContentBlock>,
|
|
thoughts: Vec<ContentBlock>,
|
|
tools: HashMap<String, ToolCall>, // Keyed by tool_call_id
|
|
}
|
|
|
|
// On AgentMessageChunk:
|
|
message.content_blocks.push(content);
|
|
|
|
// On AgentThoughtChunk:
|
|
message.thoughts.push(content);
|
|
|
|
// On ToolCall:
|
|
message.tools.insert(tool_call.id.clone(), tool_call);
|
|
|
|
// On ToolCallUpdate:
|
|
message.tools.insert(tool_call_id.clone(), tool_call); // Replace, not merge
|
|
```
|
|
|
|
**Key Insight:** ToolCallUpdate sends the **complete tool state**, not a delta. Always replace the existing tool call entry.
|
|
|
|
## What Stays the Same
|
|
|
|
The following events and types are **unchanged** and require no migration:
|
|
|
|
### Session Events
|
|
- `Event::SessionCreated`
|
|
- `Event::SessionUpdated`
|
|
- `Event::SessionDeleted`
|
|
- `Event::SessionsListed`
|
|
|
|
### Message Lifecycle Events
|
|
- `Event::MessageStarted`
|
|
- `Event::MessageCompleted`
|
|
- `Event::MessageDeleted`
|
|
|
|
### Connector Events
|
|
- `Event::ConnectorStateChanged`
|
|
|
|
### Types
|
|
- `Session`
|
|
- `SessionMetadata` (extended with optional fields, but backward compatible)
|
|
- `Message`
|
|
- `MessageMetadata`
|
|
- `MessageRole`
|
|
- `MessageStatus`
|
|
- `MessagePart` (still used for completed messages)
|
|
|
|
## Common Migration Issues
|
|
|
|
### Issue 1: Pattern Matching Exhaustiveness
|
|
|
|
**Problem:** Compiler errors about non-exhaustive patterns after removing `MessagePartAdded`.
|
|
|
|
**Solution:** Remove any match arms for `MessagePartAdded` and add `SessionUpdate` handling.
|
|
|
|
### Issue 2: Field Name Mismatches
|
|
|
|
**Problem:** `MessagePart::Text { content }` vs `ContentBlock::Text { text }`
|
|
|
|
**Solution:** Update field access from `content` to `text`.
|
|
|
|
### Issue 3: Tool Call State Management
|
|
|
|
**Problem:** Treating `ToolCallUpdate` as a delta instead of complete state.
|
|
|
|
**Solution:** Replace the entire tool call entry when receiving `ToolCallUpdate`, don't try to merge.
|
|
|
|
**Incorrect:**
|
|
```rust
|
|
// DON'T do this
|
|
if let Some(existing) = tools.get_mut(&tool_call_id) {
|
|
existing.content.extend(tool_call.content); // Wrong!
|
|
existing.status = tool_call.status;
|
|
}
|
|
```
|
|
|
|
**Correct:**
|
|
```rust
|
|
// DO this
|
|
tools.insert(tool_call_id.clone(), tool_call); // Replace completely
|
|
```
|
|
|
|
### Issue 4: Missing _meta Fields
|
|
|
|
**Problem:** Trying to access `_meta` that might be `None`.
|
|
|
|
**Solution:** Always use `Option` handling or provide defaults.
|
|
|
|
```rust
|
|
match update {
|
|
SessionUpdate::AgentMessageChunk { _meta, .. } => {
|
|
if let Some(meta) = _meta {
|
|
if let Some(provider) = &meta.provider {
|
|
println!("Provider: {}", provider.name);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
```
|
|
|
|
## Testing Your Migration
|
|
|
|
### Test Checklist
|
|
|
|
- [ ] Text streaming displays correctly
|
|
- [ ] Agent thoughts appear in designated section
|
|
- [ ] User messages are distinguished from agent messages
|
|
- [ ] Tool calls show initial pending state
|
|
- [ ] Tool calls update with running status
|
|
- [ ] Tool calls complete successfully
|
|
- [ ] Tool errors display with error messages
|
|
- [ ] File references render as links
|
|
- [ ] Multiple content chunks accumulate properly
|
|
- [ ] Tool call state replaces (not merges) on update
|
|
|
|
### Migration Test Example
|
|
|
|
```rust
|
|
#[test]
|
|
fn test_migration_text_streaming() {
|
|
let event = Event::SessionUpdate {
|
|
session_id: "test_session".to_string(),
|
|
update: SessionUpdate::AgentMessageChunk {
|
|
message_id: "msg_1".to_string(),
|
|
content: ContentBlock::Text {
|
|
text: "Hello".to_string(),
|
|
},
|
|
_meta: None,
|
|
},
|
|
};
|
|
|
|
// Your handler should process this correctly
|
|
handle_event(event);
|
|
|
|
// Assert expected state changes
|
|
assert_eq!(get_message_text("msg_1"), "Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn test_migration_tool_lifecycle() {
|
|
use dirigent_protocol::ToolCallStatus;
|
|
|
|
// 1. Initial tool call
|
|
handle_event(Event::SessionUpdate {
|
|
session_id: "test".to_string(),
|
|
update: SessionUpdate::ToolCall {
|
|
message_id: "msg_1".to_string(),
|
|
tool_call: ToolCall {
|
|
id: "call_1".to_string(),
|
|
tool_name: "bash".to_string(),
|
|
status: ToolCallStatus::Pending,
|
|
content: vec![],
|
|
raw_input: None,
|
|
raw_output: None,
|
|
title: None,
|
|
error: None,
|
|
metadata: None,
|
|
},
|
|
_meta: None,
|
|
},
|
|
});
|
|
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Pending);
|
|
|
|
// 2. Tool starts running
|
|
handle_event(Event::SessionUpdate {
|
|
session_id: "test".to_string(),
|
|
update: SessionUpdate::ToolCallUpdate {
|
|
message_id: "msg_1".to_string(),
|
|
tool_call_id: "call_1".to_string(),
|
|
tool_call: ToolCall {
|
|
id: "call_1".to_string(),
|
|
tool_name: "bash".to_string(),
|
|
status: ToolCallStatus::Running,
|
|
content: vec![],
|
|
raw_input: None,
|
|
raw_output: None,
|
|
title: None,
|
|
error: None,
|
|
metadata: None,
|
|
},
|
|
_meta: None,
|
|
},
|
|
});
|
|
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Running);
|
|
|
|
// 3. Tool completes with output
|
|
handle_event(Event::SessionUpdate {
|
|
session_id: "test".to_string(),
|
|
update: SessionUpdate::ToolCallUpdate {
|
|
message_id: "msg_1".to_string(),
|
|
tool_call_id: "call_1".to_string(),
|
|
tool_call: ToolCall {
|
|
id: "call_1".to_string(),
|
|
tool_name: "bash".to_string(),
|
|
status: ToolCallStatus::Completed,
|
|
content: vec![
|
|
ContentBlock::Text {
|
|
text: "Done!".to_string(),
|
|
},
|
|
],
|
|
raw_input: None,
|
|
raw_output: Some(serde_json::json!({"exit_code": 0})),
|
|
title: None,
|
|
error: None,
|
|
metadata: None,
|
|
},
|
|
_meta: None,
|
|
},
|
|
});
|
|
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Completed);
|
|
assert_eq!(get_tool_output("call_1"), "Done!");
|
|
}
|
|
```
|
|
|
|
## Getting Help
|
|
|
|
If you encounter issues during migration:
|
|
|
|
1. Check the [streaming_model.md](streaming_model.md) documentation for detailed API information
|
|
2. Review the [examples/](../examples/) directory for working code samples
|
|
3. Examine the [CHANGELOG.md](../CHANGELOG.md) for version-specific details
|
|
4. Look at the protocol source code tests for reference implementations
|
|
|
|
## Quick Reference: Event Type Mapping
|
|
|
|
| 0.1.x Event | 0.2.0 Event | Notes |
|
|
|-------------|-------------|-------|
|
|
| `MessagePartAdded` with `Text` | `SessionUpdate::AgentMessageChunk` or `UserMessageChunk` | Split by role |
|
|
| `MessagePartAdded` with `Thinking` | `SessionUpdate::AgentThoughtChunk` | Dedicated type |
|
|
| `MessagePartAdded` with `Tool` | `SessionUpdate::ToolCall` + `ToolCallUpdate` | Split lifecycle |
|
|
| `MessagePartAdded` with `File` | `ContentBlock::ResourceLink` in chunks | Different structure |
|
|
| All other events | Unchanged | No migration needed |
|
|
|
|
## Summary
|
|
|
|
The 0.2.0 migration primarily involves:
|
|
|
|
1. Replacing `Event::MessagePartAdded` pattern matching with `Event::SessionUpdate`
|
|
2. Using `SessionUpdate` variants instead of `MessagePart` variants
|
|
3. Accessing `ContentBlock::Text { text }` instead of `MessagePart::Text { content }`
|
|
4. Handling tool lifecycle with separate `ToolCall` and `ToolCallUpdate` events
|
|
5. Managing tool state as complete snapshots, not deltas
|
|
|
|
The new model provides better structure, clearer semantics, and improved alignment with ACP standards while maintaining most of your existing code.
|