sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
# Migration Guide: Dirigent Protocol 0.1.x → 0.2.0
|
||||
|
||||
## Overview
|
||||
|
||||
Version 0.2.0 introduces a **new ACP-style streaming model** while maintaining backward compatibility with most existing code. This guide will help you migrate to the new `SessionUpdate` event system.
|
||||
|
||||
## Breaking Changes Summary
|
||||
|
||||
### Removed: Event::MessagePartAdded
|
||||
|
||||
The `MessagePartAdded` event variant has been **removed** from the `Event` enum. This was the primary breaking change in 0.2.0.
|
||||
|
||||
**What was removed:**
|
||||
```rust
|
||||
// This no longer exists in 0.2.0
|
||||
Event::MessagePartAdded {
|
||||
session_id: String,
|
||||
message_id: String,
|
||||
part: MessagePart,
|
||||
}
|
||||
```
|
||||
|
||||
**Replaced with:**
|
||||
```rust
|
||||
// New in 0.2.0
|
||||
Event::SessionUpdate {
|
||||
session_id: String,
|
||||
update: SessionUpdate,
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Patterns
|
||||
|
||||
### Pattern 1: Basic Text Streaming
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, MessagePart};
|
||||
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Text { content, .. } => {
|
||||
println!("Text from {}: {}", message_id, content);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
|
||||
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { message_id, content, .. } |
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
println!("Text from {}: {}", message_id, text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- Use `SessionUpdate` instead of `MessagePartAdded`
|
||||
- Separate `UserMessageChunk` and `AgentMessageChunk` variants
|
||||
- `ContentBlock::Text { text }` instead of `MessagePart::Text { content }`
|
||||
- Field renamed: `content` → `text`
|
||||
|
||||
### Pattern 2: Thinking/Reasoning Content
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Thinking { content, .. } => {
|
||||
println!("Agent thinking: {}", content);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::AgentThoughtChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
println!("Agent thinking: {}", text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- `MessagePart::Thinking` → `SessionUpdate::AgentThoughtChunk`
|
||||
- Content wrapped in `ContentBlock::Text`
|
||||
|
||||
### Pattern 3: Tool Calls
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Tool {
|
||||
tool_name,
|
||||
tool_call_id,
|
||||
status,
|
||||
input,
|
||||
output,
|
||||
..
|
||||
} => {
|
||||
println!("Tool {}: {:?}", tool_name, status);
|
||||
if let Some(out) = output {
|
||||
println!("Output: {}", out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
use dirigent_protocol::ToolCallStatus;
|
||||
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
// Initial tool call
|
||||
SessionUpdate::ToolCall { message_id, tool_call, .. } => {
|
||||
println!("Tool {}: {:?}", tool_call.tool_name, tool_call.status);
|
||||
}
|
||||
|
||||
// Tool call updates (progress, completion, errors)
|
||||
SessionUpdate::ToolCallUpdate { message_id, tool_call_id, tool_call, .. } => {
|
||||
println!("Tool {} updated: {:?}", tool_call.tool_name, tool_call.status);
|
||||
|
||||
// Output is now in content blocks
|
||||
for content in &tool_call.content {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
println!("Output: {}", text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if tool_call.status == ToolCallStatus::Error {
|
||||
if let Some(error) = &tool_call.error {
|
||||
println!("Error: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- Tool lifecycle split into `ToolCall` (initial) and `ToolCallUpdate` (updates)
|
||||
- Tool output now in `tool_call.content: Vec<ContentBlock>`
|
||||
- Structured `ToolCall` type with multiple fields
|
||||
- Explicit `ToolCallStatus` enum (Pending/Running/Completed/Error)
|
||||
- Error information in dedicated `error` field
|
||||
|
||||
### Pattern 4: File References
|
||||
|
||||
#### Before (0.1.x)
|
||||
```rust
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::File { path, name, mime_type, .. } => {
|
||||
println!("File reference: {} ({})", name.unwrap_or_default(), path);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
#### After (0.2.0)
|
||||
```rust
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::ResourceLink { uri, name, mime_type } => {
|
||||
println!("Resource: {} ({})",
|
||||
name.as_deref().unwrap_or("unnamed"),
|
||||
uri
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- `MessagePart::File` → `ContentBlock::ResourceLink`
|
||||
- Field renamed: `path` → `uri` (more generic)
|
||||
- Can appear in any message chunk type
|
||||
|
||||
## Complete Migration Example
|
||||
|
||||
Here's a complete before/after example showing a typical event handler:
|
||||
|
||||
### Before (0.1.x)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, MessagePart};
|
||||
|
||||
fn handle_event(event: Event) {
|
||||
match event {
|
||||
Event::MessagePartAdded { session_id, message_id, part } => {
|
||||
match part {
|
||||
MessagePart::Text { content, .. } => {
|
||||
append_text(&message_id, &content);
|
||||
}
|
||||
MessagePart::Thinking { content, .. } => {
|
||||
show_thinking(&message_id, &content);
|
||||
}
|
||||
MessagePart::Tool { tool_name, tool_call_id, status, output, .. } => {
|
||||
update_tool_display(&tool_call_id, &tool_name, status, output.as_deref());
|
||||
}
|
||||
MessagePart::File { path, name, .. } => {
|
||||
add_file_reference(&message_id, &path, name.as_deref());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::MessageStarted { session_id, message_id, .. } => {
|
||||
create_message_container(&message_id);
|
||||
}
|
||||
Event::MessageCompleted { session_id, message_id, .. } => {
|
||||
finalize_message(&message_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After (0.2.0)
|
||||
```rust
|
||||
use dirigent_protocol::{Event, SessionUpdate, ContentBlock, ToolCallStatus};
|
||||
|
||||
fn handle_event(event: Event) {
|
||||
match event {
|
||||
Event::SessionUpdate { session_id, update } => {
|
||||
match update {
|
||||
SessionUpdate::UserMessageChunk { message_id, content, .. } |
|
||||
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
|
||||
match content {
|
||||
ContentBlock::Text { text } => {
|
||||
append_text(&message_id, &text);
|
||||
}
|
||||
ContentBlock::ResourceLink { uri, name, .. } => {
|
||||
add_file_reference(&message_id, &uri, name.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SessionUpdate::AgentThoughtChunk { message_id, content, .. } => {
|
||||
if let ContentBlock::Text { text } = content {
|
||||
show_thinking(&message_id, &text);
|
||||
}
|
||||
}
|
||||
|
||||
SessionUpdate::ToolCall { message_id, tool_call, .. } => {
|
||||
create_tool_display(
|
||||
&tool_call.id,
|
||||
&tool_call.tool_name,
|
||||
tool_call.status,
|
||||
);
|
||||
}
|
||||
|
||||
SessionUpdate::ToolCallUpdate { tool_call_id, tool_call, .. } => {
|
||||
// Extract output text from content blocks
|
||||
let output_text = tool_call.content.iter()
|
||||
.filter_map(|c| match c {
|
||||
ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
update_tool_display(
|
||||
&tool_call_id,
|
||||
&tool_call.tool_name,
|
||||
tool_call.status,
|
||||
if output_text.is_empty() { None } else { Some(&output_text) },
|
||||
);
|
||||
|
||||
// Handle errors
|
||||
if tool_call.status == ToolCallStatus::Error {
|
||||
if let Some(error) = &tool_call.error {
|
||||
show_tool_error(&tool_call_id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These events remain unchanged
|
||||
Event::MessageStarted { session_id, message_id, .. } => {
|
||||
create_message_container(&message_id);
|
||||
}
|
||||
Event::MessageCompleted { session_id, message_id, .. } => {
|
||||
finalize_message(&message_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI State Management Changes
|
||||
|
||||
### Before: Simple Append Model
|
||||
|
||||
```rust
|
||||
struct MessageState {
|
||||
id: String,
|
||||
text: String,
|
||||
thinking: String,
|
||||
tools: Vec<ToolDisplay>,
|
||||
}
|
||||
|
||||
// On MessagePartAdded with Text:
|
||||
message.text.push_str(&content);
|
||||
```
|
||||
|
||||
### After: ContentBlock Streaming
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct MessageState {
|
||||
id: String,
|
||||
content_blocks: Vec<ContentBlock>,
|
||||
thoughts: Vec<ContentBlock>,
|
||||
tools: HashMap<String, ToolCall>, // Keyed by tool_call_id
|
||||
}
|
||||
|
||||
// On AgentMessageChunk:
|
||||
message.content_blocks.push(content);
|
||||
|
||||
// On AgentThoughtChunk:
|
||||
message.thoughts.push(content);
|
||||
|
||||
// On ToolCall:
|
||||
message.tools.insert(tool_call.id.clone(), tool_call);
|
||||
|
||||
// On ToolCallUpdate:
|
||||
message.tools.insert(tool_call_id.clone(), tool_call); // Replace, not merge
|
||||
```
|
||||
|
||||
**Key Insight:** ToolCallUpdate sends the **complete tool state**, not a delta. Always replace the existing tool call entry.
|
||||
|
||||
## What Stays the Same
|
||||
|
||||
The following events and types are **unchanged** and require no migration:
|
||||
|
||||
### Session Events
|
||||
- `Event::SessionCreated`
|
||||
- `Event::SessionUpdated`
|
||||
- `Event::SessionDeleted`
|
||||
- `Event::SessionsListed`
|
||||
|
||||
### Message Lifecycle Events
|
||||
- `Event::MessageStarted`
|
||||
- `Event::MessageCompleted`
|
||||
- `Event::MessageDeleted`
|
||||
|
||||
### Connector Events
|
||||
- `Event::ConnectorStateChanged`
|
||||
|
||||
### Types
|
||||
- `Session`
|
||||
- `SessionMetadata` (extended with optional fields, but backward compatible)
|
||||
- `Message`
|
||||
- `MessageMetadata`
|
||||
- `MessageRole`
|
||||
- `MessageStatus`
|
||||
- `MessagePart` (still used for completed messages)
|
||||
|
||||
## Common Migration Issues
|
||||
|
||||
### Issue 1: Pattern Matching Exhaustiveness
|
||||
|
||||
**Problem:** Compiler errors about non-exhaustive patterns after removing `MessagePartAdded`.
|
||||
|
||||
**Solution:** Remove any match arms for `MessagePartAdded` and add `SessionUpdate` handling.
|
||||
|
||||
### Issue 2: Field Name Mismatches
|
||||
|
||||
**Problem:** `MessagePart::Text { content }` vs `ContentBlock::Text { text }`
|
||||
|
||||
**Solution:** Update field access from `content` to `text`.
|
||||
|
||||
### Issue 3: Tool Call State Management
|
||||
|
||||
**Problem:** Treating `ToolCallUpdate` as a delta instead of complete state.
|
||||
|
||||
**Solution:** Replace the entire tool call entry when receiving `ToolCallUpdate`, don't try to merge.
|
||||
|
||||
**Incorrect:**
|
||||
```rust
|
||||
// DON'T do this
|
||||
if let Some(existing) = tools.get_mut(&tool_call_id) {
|
||||
existing.content.extend(tool_call.content); // Wrong!
|
||||
existing.status = tool_call.status;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```rust
|
||||
// DO this
|
||||
tools.insert(tool_call_id.clone(), tool_call); // Replace completely
|
||||
```
|
||||
|
||||
### Issue 4: Missing _meta Fields
|
||||
|
||||
**Problem:** Trying to access `_meta` that might be `None`.
|
||||
|
||||
**Solution:** Always use `Option` handling or provide defaults.
|
||||
|
||||
```rust
|
||||
match update {
|
||||
SessionUpdate::AgentMessageChunk { _meta, .. } => {
|
||||
if let Some(meta) = _meta {
|
||||
if let Some(provider) = &meta.provider {
|
||||
println!("Provider: {}", provider.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Your Migration
|
||||
|
||||
### Test Checklist
|
||||
|
||||
- [ ] Text streaming displays correctly
|
||||
- [ ] Agent thoughts appear in designated section
|
||||
- [ ] User messages are distinguished from agent messages
|
||||
- [ ] Tool calls show initial pending state
|
||||
- [ ] Tool calls update with running status
|
||||
- [ ] Tool calls complete successfully
|
||||
- [ ] Tool errors display with error messages
|
||||
- [ ] File references render as links
|
||||
- [ ] Multiple content chunks accumulate properly
|
||||
- [ ] Tool call state replaces (not merges) on update
|
||||
|
||||
### Migration Test Example
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_migration_text_streaming() {
|
||||
let event = Event::SessionUpdate {
|
||||
session_id: "test_session".to_string(),
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_1".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Your handler should process this correctly
|
||||
handle_event(event);
|
||||
|
||||
// Assert expected state changes
|
||||
assert_eq!(get_message_text("msg_1"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_tool_lifecycle() {
|
||||
use dirigent_protocol::ToolCallStatus;
|
||||
|
||||
// 1. Initial tool call
|
||||
handle_event(Event::SessionUpdate {
|
||||
session_id: "test".to_string(),
|
||||
update: SessionUpdate::ToolCall {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
});
|
||||
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Pending);
|
||||
|
||||
// 2. Tool starts running
|
||||
handle_event(Event::SessionUpdate {
|
||||
session_id: "test".to_string(),
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
});
|
||||
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Running);
|
||||
|
||||
// 3. Tool completes with output
|
||||
handle_event(Event::SessionUpdate {
|
||||
session_id: "test".to_string(),
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_1".to_string(),
|
||||
tool_call_id: "call_1".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "Done!".to_string(),
|
||||
},
|
||||
],
|
||||
raw_input: None,
|
||||
raw_output: Some(serde_json::json!({"exit_code": 0})),
|
||||
title: None,
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
});
|
||||
assert_eq!(get_tool_status("call_1"), ToolCallStatus::Completed);
|
||||
assert_eq!(get_tool_output("call_1"), "Done!");
|
||||
}
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
||||
1. Check the [streaming_model.md](streaming_model.md) documentation for detailed API information
|
||||
2. Review the [examples/](../examples/) directory for working code samples
|
||||
3. Examine the [CHANGELOG.md](../CHANGELOG.md) for version-specific details
|
||||
4. Look at the protocol source code tests for reference implementations
|
||||
|
||||
## Quick Reference: Event Type Mapping
|
||||
|
||||
| 0.1.x Event | 0.2.0 Event | Notes |
|
||||
|-------------|-------------|-------|
|
||||
| `MessagePartAdded` with `Text` | `SessionUpdate::AgentMessageChunk` or `UserMessageChunk` | Split by role |
|
||||
| `MessagePartAdded` with `Thinking` | `SessionUpdate::AgentThoughtChunk` | Dedicated type |
|
||||
| `MessagePartAdded` with `Tool` | `SessionUpdate::ToolCall` + `ToolCallUpdate` | Split lifecycle |
|
||||
| `MessagePartAdded` with `File` | `ContentBlock::ResourceLink` in chunks | Different structure |
|
||||
| All other events | Unchanged | No migration needed |
|
||||
|
||||
## Summary
|
||||
|
||||
The 0.2.0 migration primarily involves:
|
||||
|
||||
1. Replacing `Event::MessagePartAdded` pattern matching with `Event::SessionUpdate`
|
||||
2. Using `SessionUpdate` variants instead of `MessagePart` variants
|
||||
3. Accessing `ContentBlock::Text { text }` instead of `MessagePart::Text { content }`
|
||||
4. Handling tool lifecycle with separate `ToolCall` and `ToolCallUpdate` events
|
||||
5. Managing tool state as complete snapshots, not deltas
|
||||
|
||||
The new model provides better structure, clearer semantics, and improved alignment with ACP standards while maintaining most of your existing code.
|
||||
@@ -0,0 +1,476 @@
|
||||
# Dirigent Protocol Streaming Model
|
||||
|
||||
## Overview
|
||||
|
||||
The Dirigent Protocol uses an **ACP-style streaming model** built around `SessionUpdate` events. This model provides granular, real-time updates during agent interactions, enabling responsive UIs and structured content representation.
|
||||
|
||||
Version: 0.2.0
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### SessionUpdate Events
|
||||
|
||||
All streaming content is delivered through `SessionUpdate` variants wrapped in `Event::SessionUpdate`:
|
||||
|
||||
```rust
|
||||
pub enum Event {
|
||||
// ... other events
|
||||
SessionUpdate {
|
||||
session_id: String,
|
||||
update: SessionUpdate,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `SessionUpdate` enum contains five variants for different types of streaming updates:
|
||||
|
||||
```rust
|
||||
pub enum SessionUpdate {
|
||||
UserMessageChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
AgentMessageChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
AgentThoughtChunk { message_id: String, content: ContentBlock, _meta: Option<Meta> },
|
||||
ToolCall { message_id: String, tool_call: ToolCall, _meta: Option<Meta> },
|
||||
ToolCallUpdate { message_id: String, tool_call_id: String, tool_call: ToolCall, _meta: Option<Meta> },
|
||||
}
|
||||
```
|
||||
|
||||
### ContentBlock Types
|
||||
|
||||
Content is represented using structured `ContentBlock` variants:
|
||||
|
||||
```rust
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
ResourceLink {
|
||||
uri: String,
|
||||
name: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
},
|
||||
// Future: Resource, Image, Audio (marked as out-of-scope for phase 1)
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- `Text` is the primary content type for all textual output
|
||||
- `ResourceLink` represents file references without embedding full content
|
||||
- Future expansions will include embedded resources, images, and audio
|
||||
|
||||
### Provider Metadata (_meta)
|
||||
|
||||
All `SessionUpdate` variants support optional `_meta` fields for provider-specific information:
|
||||
|
||||
```rust
|
||||
pub struct Meta {
|
||||
pub provider: Option<ProviderMeta>,
|
||||
pub extra: HashMap<String, Value>, // Arbitrary additional fields
|
||||
}
|
||||
|
||||
pub struct ProviderMeta {
|
||||
pub name: String, // e.g., "opencode", "anthropic"
|
||||
pub original_ids: Option<HashMap<String, String>>, // Original provider IDs
|
||||
pub raw_excerpt: Option<Value>, // Minimal raw payload for debugging
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
- Adapters populate `_meta` to preserve provider-specific information
|
||||
- Consumers can use this for debugging, telemetry, or provider-specific features
|
||||
- The `extra` map allows arbitrary fields for forward compatibility
|
||||
|
||||
## SessionUpdate Variants
|
||||
|
||||
### 1. UserMessageChunk
|
||||
|
||||
Represents streaming chunks of user message content.
|
||||
|
||||
```rust
|
||||
SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_abc123".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "What's the capital of France?".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Streaming user input being typed
|
||||
- Echo of user input from server
|
||||
- Multi-part user messages being assembled
|
||||
|
||||
### 2. AgentMessageChunk
|
||||
|
||||
Represents streaming chunks of agent response content.
|
||||
|
||||
```rust
|
||||
SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_def456".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "The capital of France is ".to_string(),
|
||||
},
|
||||
_meta: Some(Meta {
|
||||
provider: Some(ProviderMeta {
|
||||
name: "opencode".to_string(),
|
||||
original_ids: Some(HashMap::from([
|
||||
("message_id".to_string(), "original_123".to_string()),
|
||||
])),
|
||||
raw_excerpt: None,
|
||||
}),
|
||||
extra: HashMap::new(),
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Agent's response text being generated
|
||||
- Final answer content
|
||||
- Any visible agent output
|
||||
|
||||
**Key distinction from AgentThoughtChunk:**
|
||||
- `AgentMessageChunk`: Visible output intended for the user
|
||||
- `AgentThoughtChunk`: Internal reasoning, typically hidden or collapsible
|
||||
|
||||
### 3. AgentThoughtChunk
|
||||
|
||||
Represents streaming chunks of agent internal reasoning (thinking, planning).
|
||||
|
||||
```rust
|
||||
SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_ghi789".to_string(),
|
||||
content: ContentBlock::Text {
|
||||
text: "I need to look up Paris in my knowledge base...".to_string(),
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Agent's internal reasoning process
|
||||
- "Chain of thought" content
|
||||
- Planning or decision-making process
|
||||
- Content typically displayed in collapsible sections
|
||||
|
||||
**UI Conventions:**
|
||||
- Often hidden by default or shown in a separate "Thinking" section
|
||||
- May be styled differently (e.g., italics, muted colors)
|
||||
- Can be collapsed to save screen space
|
||||
|
||||
### 4. ToolCall
|
||||
|
||||
Represents the initiation or current state of a tool call.
|
||||
|
||||
```rust
|
||||
SessionUpdate::ToolCall {
|
||||
message_id: "msg_jkl012".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_xyz789".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({
|
||||
"file_path": "/path/to/file.txt"
|
||||
})),
|
||||
raw_output: None,
|
||||
title: Some("Read file.txt".to_string()),
|
||||
error: None,
|
||||
metadata: None,
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Tool call is first initiated
|
||||
- Sending a snapshot of current tool state
|
||||
- Re-sending full tool state after reconnection
|
||||
|
||||
**ToolCallStatus Lifecycle:**
|
||||
- `Pending` → Tool call created but not yet executing
|
||||
- `Running` → Tool call actively executing
|
||||
- `Completed` → Tool call finished successfully
|
||||
- `Error` → Tool call failed
|
||||
|
||||
### 5. ToolCallUpdate
|
||||
|
||||
Represents an update to an existing tool call (status change, new content).
|
||||
|
||||
```rust
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_jkl012".to_string(),
|
||||
tool_call_id: "call_xyz789".to_string(),
|
||||
tool_call: ToolCall {
|
||||
id: "call_xyz789".to_string(),
|
||||
tool_name: "read_file".to_string(),
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "Reading file...".to_string(),
|
||||
},
|
||||
],
|
||||
raw_input: Some(json!({
|
||||
"file_path": "/path/to/file.txt"
|
||||
})),
|
||||
raw_output: None,
|
||||
title: Some("Read file.txt".to_string()),
|
||||
error: None,
|
||||
metadata: Some(json!({
|
||||
"bytes_read": 1024
|
||||
})),
|
||||
},
|
||||
_meta: None,
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Tool status changes (Pending → Running → Completed/Error)
|
||||
- New output content available
|
||||
- Progress updates
|
||||
- Error state reached
|
||||
|
||||
**Note:** The full `ToolCall` is sent each time, not a delta. Consumers should replace the previous tool call state with the new one.
|
||||
|
||||
## Tool Call Lifecycle
|
||||
|
||||
Understanding the tool call lifecycle is essential for proper UI implementation:
|
||||
|
||||
```rust
|
||||
// 1. Tool call initiated
|
||||
SessionUpdate::ToolCall {
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Tool starts executing
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![],
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tool produces output
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Running,
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "Output line 1" },
|
||||
],
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 4a. Tool completes successfully
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "Output line 1" },
|
||||
ContentBlock::Text { text: "Done!" },
|
||||
],
|
||||
raw_output: Some(json!({"success": true})),
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. Or tool fails with error
|
||||
SessionUpdate::ToolCallUpdate {
|
||||
tool_call_id: "call_123",
|
||||
tool_call: ToolCall {
|
||||
id: "call_123",
|
||||
status: ToolCallStatus::Error,
|
||||
content: vec![
|
||||
ContentBlock::Text { text: "Error output" },
|
||||
],
|
||||
error: Some("File not found".to_string()),
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI Implementation Guidelines:**
|
||||
- Track tool calls by `id` in a HashMap
|
||||
- On `ToolCall`: create new entry
|
||||
- On `ToolCallUpdate`: replace existing entry (not delta)
|
||||
- Display status with appropriate visual indicators
|
||||
- Show `content` blocks as streaming output
|
||||
- Display `error` when status is `Error`
|
||||
- Use `title` for tool call heading if available
|
||||
|
||||
## Typical Message Flow
|
||||
|
||||
Here's a complete example showing a typical agent interaction:
|
||||
|
||||
```rust
|
||||
// 1. User sends message
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::UserMessageChunk {
|
||||
message_id: "msg_user_1",
|
||||
content: ContentBlock::Text {
|
||||
text: "Read and summarize config.toml",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 2. Agent starts thinking
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::AgentThoughtChunk {
|
||||
message_id: "msg_agent_1",
|
||||
content: ContentBlock::Text {
|
||||
text: "I need to read the file first...",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 3. Agent initiates tool call
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::ToolCall {
|
||||
message_id: "msg_agent_1",
|
||||
tool_call: ToolCall {
|
||||
id: "call_read_1",
|
||||
tool_name: "read_file",
|
||||
status: ToolCallStatus::Pending,
|
||||
content: vec![],
|
||||
raw_input: Some(json!({"path": "config.toml"})),
|
||||
title: Some("Read config.toml"),
|
||||
// ...
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 4. Tool starts executing
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_agent_1",
|
||||
tool_call_id: "call_read_1",
|
||||
tool_call: ToolCall {
|
||||
id: "call_read_1",
|
||||
status: ToolCallStatus::Running,
|
||||
// ...
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 5. Tool completes
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::ToolCallUpdate {
|
||||
message_id: "msg_agent_1",
|
||||
tool_call_id: "call_read_1",
|
||||
tool_call: ToolCall {
|
||||
id: "call_read_1",
|
||||
status: ToolCallStatus::Completed,
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "[port = 3000\n...]",
|
||||
},
|
||||
],
|
||||
raw_output: Some(json!({"bytes_read": 1024})),
|
||||
// ...
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 6. Agent responds with summary
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_1",
|
||||
content: ContentBlock::Text {
|
||||
text: "The config file sets the server port to 3000",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
|
||||
// 7. More response chunks...
|
||||
Event::SessionUpdate {
|
||||
session_id: "session_123",
|
||||
update: SessionUpdate::AgentMessageChunk {
|
||||
message_id: "msg_agent_1",
|
||||
content: ContentBlock::Text {
|
||||
text: " and enables debug mode.",
|
||||
},
|
||||
_meta: None,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Content vs MessagePart
|
||||
|
||||
**Important Distinction:**
|
||||
|
||||
- **ContentBlock**: Streaming content representation (used in `SessionUpdate`)
|
||||
- Designed for real-time rendering
|
||||
- Granular updates
|
||||
- MCP-compatible structure
|
||||
|
||||
- **MessagePart**: Completed message content (legacy, still supported)
|
||||
- Used in stored/completed messages
|
||||
- May include additional fields for history
|
||||
- Compatibility with existing code
|
||||
|
||||
**Migration Path:** The protocol supports both models. New code should prefer `SessionUpdate` with `ContentBlock` for streaming, while `MessagePart` remains available for compatibility and completed message storage.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Consumers
|
||||
|
||||
1. **Track by message_id**: Group all chunks/updates for the same message
|
||||
2. **Handle tool calls separately**: Maintain a HashMap of tool calls by `tool_call_id`
|
||||
3. **Replace, don't merge**: `ToolCallUpdate` sends complete state, not deltas
|
||||
4. **Use _meta for debugging**: Provider metadata helps with troubleshooting
|
||||
5. **Distinguish thoughts from messages**: Render `AgentThoughtChunk` differently
|
||||
|
||||
### For Adapters
|
||||
|
||||
1. **Always include message_id**: Every update must reference its message
|
||||
2. **Preserve provider info in _meta**: Store original IDs for debugging
|
||||
3. **Send complete tool state**: Include all tool call fields in updates
|
||||
4. **Use appropriate chunk types**: User/Agent/Thought for correct semantics
|
||||
5. **Keep _meta minimal**: Avoid large raw payloads in production
|
||||
|
||||
### For UI Developers
|
||||
|
||||
1. **Stream incrementally**: Append chunks as they arrive
|
||||
2. **Show tool status visually**: Use icons/colors for Pending/Running/Completed/Error
|
||||
3. **Make thoughts collapsible**: Don't clutter the main conversation
|
||||
4. **Handle reconnection**: Be prepared to receive full state snapshots
|
||||
5. **Display errors prominently**: Show tool errors clearly to users
|
||||
|
||||
## Future Extensions
|
||||
|
||||
The following features are planned but not yet implemented:
|
||||
|
||||
- **ResourceBlock**: Embedded resource content (text/blob)
|
||||
- **Image/Audio blocks**: Rich media content
|
||||
- **Plan updates**: Agent planning and mode switching
|
||||
- **Permissions**: Request/reply for user permissions
|
||||
- **Stop reasons**: Detailed completion reasons
|
||||
|
||||
See the protocol roadmap for timeline and details.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Migration from 0.1.x](migration_from_0.1.md) - Upgrading from older versions
|
||||
- [CHANGELOG.md](../CHANGELOG.md) - Version history and breaking changes
|
||||
- [examples/](../examples/) - Code examples demonstrating usage
|
||||
Reference in New Issue
Block a user