use std::collections::HashMap;
use serde_json::{json, Value};
use crate::permissions::ToolInputCache;
use crate::types::content::{
Base64ImageSource, ContentBlock, ImageBlock, ImageSource, TextBlock, ThinkingBlock,
ToolResultBlock, ToolResultContent, ToolUseBlock, UserContent,
};
use crate::types::messages::{
AssistantMessage, AssistantMessageInner, Message, StreamEvent, UserMessage, UserMessageInner,
};
use crate::wire::{SessionUpdate, WireContentBlock};
pub(crate) fn translate_content_block(wire: &WireContentBlock) -> ContentBlock {
match wire.content_type.as_str() {
"text" => ContentBlock::Text(TextBlock {
text: wire.text.clone().unwrap_or_default(),
extra: Value::Object(Default::default()),
}),
"image" => ContentBlock::Image(ImageBlock {
source: ImageSource::Base64(Base64ImageSource {
media_type: wire
.mime_type
.clone()
.unwrap_or_else(|| "image/png".to_string()),
data: wire.data.clone().unwrap_or_default(),
}),
extra: Value::Object(Default::default()),
}),
"resource_link" => {
let name = wire.name.as_deref().unwrap_or("resource");
let uri = wire.uri.as_deref().unwrap_or("");
ContentBlock::Text(TextBlock {
text: format!("[{name}]({uri})"),
extra: Value::Object(Default::default()),
})
}
"resource" => {
let text = wire
.resource
.as_ref()
.and_then(|r| r.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
ContentBlock::Text(TextBlock {
text,
extra: Value::Object(Default::default()),
})
}
other => ContentBlock::Text(TextBlock {
text: format!("[unknown content type: {other}]"),
extra: wire.extra.clone(),
}),
}
}
pub(crate) struct TranslationContext {
pub session_id: String,
pub model: String,
accumulated_text: String,
accumulated_thinking: String,
tool_calls: HashMap<String, ToolCallState>,
tool_input_cache: Option<ToolInputCache>,
}
struct ToolCallState {
id: String,
name: String,
input: Value,
}
impl TranslationContext {
#[allow(dead_code)]
pub fn new(session_id: String, model: String) -> Self {
Self {
session_id,
model,
accumulated_text: String::new(),
accumulated_thinking: String::new(),
tool_calls: HashMap::new(),
tool_input_cache: None,
}
}
pub fn new_with_cache(session_id: String, model: String, cache: ToolInputCache) -> Self {
Self {
session_id,
model,
accumulated_text: String::new(),
accumulated_thinking: String::new(),
tool_calls: HashMap::new(),
tool_input_cache: Some(cache),
}
}
pub fn translate(&mut self, update: SessionUpdate) -> Vec<Message> {
match update {
SessionUpdate::AgentThoughtChunk { content } => {
if let Some(text) = content.text {
self.accumulated_thinking.push_str(&text);
}
vec![Message::Assistant(AssistantMessage {
message: AssistantMessageInner {
role: "assistant".to_string(),
content: vec![ContentBlock::Thinking(ThinkingBlock {
thinking: self.accumulated_thinking.clone(),
extra: Value::Object(Default::default()),
})],
model: self.model.clone(),
stop_reason: String::new(),
stop_sequence: None,
extra: Value::Object(Default::default()),
},
session_id: self.session_id.clone(),
})]
}
SessionUpdate::AgentMessageChunk { content } => {
if let Some(text) = content.text {
self.accumulated_text.push_str(&text);
}
vec![Message::Assistant(AssistantMessage {
message: AssistantMessageInner {
role: "assistant".to_string(),
content: vec![ContentBlock::Text(TextBlock {
text: self.accumulated_text.clone(),
extra: Value::Object(Default::default()),
})],
model: self.model.clone(),
stop_reason: String::new(),
stop_sequence: None,
extra: Value::Object(Default::default()),
},
session_id: self.session_id.clone(),
})]
}
SessionUpdate::ToolCall {
tool_call_id,
title,
kind,
status,
locations,
} => {
let cached_input = self.cached_input(&tool_call_id);
self.tool_calls
.entry(tool_call_id.clone())
.or_insert(ToolCallState {
id: tool_call_id.clone(),
name: title.clone(),
input: cached_input,
});
let locations_value =
serde_json::to_value(&locations).unwrap_or(Value::Array(Vec::new()));
vec![Message::StreamEvent(StreamEvent {
event_type: "tool_call".to_string(),
data: json!({
"tool_call_id": tool_call_id,
"title": title,
"kind": kind,
"status": status,
"locations": locations_value,
}),
session_id: self.session_id.clone(),
})]
}
SessionUpdate::ToolCallUpdate {
tool_call_id,
status,
content,
} => {
let mut messages = vec![Message::StreamEvent(StreamEvent {
event_type: "tool_call_update".to_string(),
data: json!({
"tool_call_id": &tool_call_id,
"status": &status,
}),
session_id: self.session_id.clone(),
})];
if status == "completed" || status == "failed" {
if !self.tool_calls.contains_key(&tool_call_id) {
let cached_input = self.cached_input(&tool_call_id);
let name = tool_call_id
.rsplit_once('-')
.map(|(prefix, _)| prefix.to_string())
.unwrap_or_else(|| tool_call_id.clone());
self.tool_calls.insert(
tool_call_id.clone(),
ToolCallState {
id: tool_call_id.clone(),
name,
input: cached_input,
},
);
}
if let Some(state) = self.tool_calls.get(&tool_call_id) {
messages.push(Message::Assistant(AssistantMessage {
message: AssistantMessageInner {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse(ToolUseBlock {
id: state.id.clone(),
name: state.name.clone(),
input: state.input.clone(),
extra: Value::Object(Default::default()),
})],
model: self.model.clone(),
stop_reason: String::new(),
stop_sequence: None,
extra: Value::Object(Default::default()),
},
session_id: self.session_id.clone(),
}));
let result_content = content
.map(|blocks| {
blocks
.iter()
.filter_map(|b| match b.content_type.as_str() {
"text" => Some(ToolResultContent::Text {
text: b.text.clone().unwrap_or_default(),
}),
"content" => {
let text = b
.extra
.get("content")
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
if text.is_empty() {
None
} else {
Some(ToolResultContent::Text { text })
}
}
_ => None,
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let is_error = status == "failed";
messages.push(Message::Assistant(AssistantMessage {
message: AssistantMessageInner {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolResult(ToolResultBlock {
tool_use_id: tool_call_id,
content: result_content,
is_error,
extra: Value::Object(Default::default()),
})],
model: self.model.clone(),
stop_reason: String::new(),
stop_sequence: None,
extra: Value::Object(Default::default()),
},
session_id: self.session_id.clone(),
}));
}
}
messages
}
SessionUpdate::Plan { entries } => {
vec![Message::StreamEvent(StreamEvent {
event_type: "plan".to_string(),
data: serde_json::to_value(&entries).unwrap_or(Value::Array(Vec::new())),
session_id: self.session_id.clone(),
})]
}
SessionUpdate::UsageUpdate { cost, size, used } => {
vec![Message::StreamEvent(StreamEvent {
event_type: "usage_update".to_string(),
data: json!({ "cost": cost, "size": size, "used": used }),
session_id: self.session_id.clone(),
})]
}
SessionUpdate::SessionInfoUpdate { title } => {
vec![Message::StreamEvent(StreamEvent {
event_type: "session_info".to_string(),
data: json!({ "title": title }),
session_id: self.session_id.clone(),
})]
}
SessionUpdate::UserMessageChunk { content } => {
let block = translate_content_block(&content);
vec![Message::User(UserMessage {
message: UserMessageInner {
role: "user".to_string(),
content: vec![block],
extra: Value::Object(Default::default()),
},
session_id: self.session_id.clone(),
})]
}
SessionUpdate::Unknown { kind, data } => {
vec![Message::StreamEvent(StreamEvent {
event_type: kind,
data,
session_id: self.session_id.clone(),
})]
}
}
}
fn cached_input(&self, tool_call_id: &str) -> Value {
self.tool_input_cache
.as_ref()
.and_then(|cache| cache.lock().ok()?.get(tool_call_id).cloned())
.unwrap_or_else(|| Value::Object(Default::default()))
}
pub fn reset_turn(&mut self) {
self.accumulated_text.clear();
self.accumulated_thinking.clear();
self.tool_calls.clear();
}
}
pub(crate) fn user_content_to_wire(content: &UserContent) -> WireContentBlock {
match content {
UserContent::Text { text } => WireContentBlock {
content_type: "text".to_string(),
text: Some(text.clone()),
..Default::default()
},
UserContent::Image { source } => match source {
ImageSource::Base64(b64) => WireContentBlock {
content_type: "image".to_string(),
data: Some(b64.data.clone()),
mime_type: Some(b64.media_type.clone()),
..Default::default()
},
ImageSource::Url(url_src) => WireContentBlock {
content_type: "image".to_string(),
uri: Some(url_src.url.clone()),
..Default::default()
},
},
}
}
#[allow(dead_code)]
pub(crate) fn prompt_to_wire(prompt: &str) -> Vec<WireContentBlock> {
vec![WireContentBlock {
content_type: "text".to_string(),
text: Some(prompt.to_string()),
..Default::default()
}]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_translate_text_content_block() {
let wire = WireContentBlock {
content_type: "text".to_string(),
text: Some("Hello, world!".to_string()),
..Default::default()
};
let block = translate_content_block(&wire);
match block {
ContentBlock::Text(t) => assert_eq!(t.text, "Hello, world!"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn test_translate_image_content_block() {
let wire = WireContentBlock {
content_type: "image".to_string(),
data: Some("iVBORw0KGgo=".to_string()),
mime_type: Some("image/png".to_string()),
..Default::default()
};
let block = translate_content_block(&wire);
match block {
ContentBlock::Image(img) => match img.source {
ImageSource::Base64(b64) => {
assert_eq!(b64.media_type, "image/png");
assert_eq!(b64.data, "iVBORw0KGgo=");
}
other => panic!("expected Base64 source, got {other:?}"),
},
other => panic!("expected Image, got {other:?}"),
}
}
#[test]
fn test_translate_image_defaults_mime_type() {
let wire = WireContentBlock {
content_type: "image".to_string(),
data: Some("abc".to_string()),
..Default::default()
};
let block = translate_content_block(&wire);
match block {
ContentBlock::Image(img) => match img.source {
ImageSource::Base64(b64) => assert_eq!(b64.media_type, "image/png"),
_ => panic!("expected Base64"),
},
_ => panic!("expected Image"),
}
}
#[test]
fn test_translate_resource_link() {
let wire = WireContentBlock {
content_type: "resource_link".to_string(),
name: Some("README".to_string()),
uri: Some("file:///project/README.md".to_string()),
..Default::default()
};
let block = translate_content_block(&wire);
match block {
ContentBlock::Text(t) => {
assert_eq!(t.text, "[README](file:///project/README.md)");
}
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn test_translate_resource_link_missing_fields_use_defaults() {
let wire = WireContentBlock {
content_type: "resource_link".to_string(),
..Default::default()
};
let block = translate_content_block(&wire);
match block {
ContentBlock::Text(t) => assert_eq!(t.text, "[resource]()"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn test_translate_resource_type() {
let wire = WireContentBlock {
content_type: "resource".to_string(),
resource: Some(json!({ "text": "file contents here" })),
..Default::default()
};
let block = translate_content_block(&wire);
match block {
ContentBlock::Text(t) => assert_eq!(t.text, "file contents here"),
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn test_translate_unknown_content_type() {
let wire = WireContentBlock {
content_type: "some_future_type".to_string(),
..Default::default()
};
let block = translate_content_block(&wire);
match block {
ContentBlock::Text(t) => {
assert!(
t.text.contains("some_future_type"),
"fallback text must name the unknown type; got: {}", t.text
);
}
other => panic!("expected Text fallback, got {other:?}"),
}
}
fn make_ctx() -> TranslationContext {
TranslationContext::new("sess-test".to_string(), "gemini-2.5-pro".to_string())
}
#[test]
fn test_translate_thought_chunk_accumulates() {
let mut ctx = make_ctx();
let text_block = WireContentBlock {
content_type: "text".to_string(),
text: Some("part1 ".to_string()),
..Default::default()
};
let msgs = ctx.translate(SessionUpdate::AgentThoughtChunk {
content: text_block,
});
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::Assistant(m) => match &m.message.content[0] {
ContentBlock::Thinking(t) => assert_eq!(t.thinking, "part1 "),
other => panic!("expected Thinking block, got {other:?}"),
},
other => panic!("expected Assistant, got {other:?}"),
}
let text_block2 = WireContentBlock {
content_type: "text".to_string(),
text: Some("part2".to_string()),
..Default::default()
};
let msgs2 = ctx.translate(SessionUpdate::AgentThoughtChunk {
content: text_block2,
});
match &msgs2[0] {
Message::Assistant(m) => match &m.message.content[0] {
ContentBlock::Thinking(t) => assert_eq!(t.thinking, "part1 part2"),
other => panic!("expected Thinking, got {other:?}"),
},
other => panic!("expected Assistant, got {other:?}"),
}
}
#[test]
fn test_translate_message_chunk_accumulates() {
let mut ctx = make_ctx();
let chunk1 = WireContentBlock {
content_type: "text".to_string(),
text: Some("Hello".to_string()),
..Default::default()
};
ctx.translate(SessionUpdate::AgentMessageChunk { content: chunk1 });
let chunk2 = WireContentBlock {
content_type: "text".to_string(),
text: Some(", world".to_string()),
..Default::default()
};
let msgs = ctx.translate(SessionUpdate::AgentMessageChunk { content: chunk2 });
match &msgs[0] {
Message::Assistant(m) => match &m.message.content[0] {
ContentBlock::Text(t) => assert_eq!(t.text, "Hello, world"),
other => panic!("expected Text, got {other:?}"),
},
other => panic!("expected Assistant, got {other:?}"),
}
}
#[test]
fn test_translate_tool_call_emits_stream_event() {
let mut ctx = make_ctx();
let msgs = ctx.translate(SessionUpdate::ToolCall {
tool_call_id: "tc-001".to_string(),
title: "Read file".to_string(),
kind: "read_file".to_string(),
status: "in_progress".to_string(),
locations: vec![],
});
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::StreamEvent(ev) => {
assert_eq!(ev.event_type, "tool_call");
assert_eq!(ev.data["tool_call_id"], "tc-001");
assert_eq!(ev.data["title"], "Read file");
assert_eq!(ev.data["kind"], "read_file");
assert_eq!(ev.data["status"], "in_progress");
assert_eq!(ev.session_id, "sess-test");
}
other => panic!("expected StreamEvent, got {other:?}"),
}
}
#[test]
fn test_translate_tool_call_update_intermediate_emits_one_message() {
let mut ctx = make_ctx();
ctx.translate(SessionUpdate::ToolCall {
tool_call_id: "tc-001".to_string(),
title: "Read file".to_string(),
kind: "read_file".to_string(),
status: "in_progress".to_string(),
locations: vec![],
});
let msgs = ctx.translate(SessionUpdate::ToolCallUpdate {
tool_call_id: "tc-001".to_string(),
status: "running".to_string(),
content: None,
});
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::StreamEvent(ev) => assert_eq!(ev.event_type, "tool_call_update"),
other => panic!("expected StreamEvent, got {other:?}"),
}
}
#[test]
fn test_translate_tool_call_update_completed_emits_three_messages() {
let mut ctx = make_ctx();
ctx.translate(SessionUpdate::ToolCall {
tool_call_id: "tc-002".to_string(),
title: "Shell".to_string(),
kind: "shell".to_string(),
status: "in_progress".to_string(),
locations: vec![],
});
let result_block = WireContentBlock {
content_type: "text".to_string(),
text: Some("output text".to_string()),
..Default::default()
};
let msgs = ctx.translate(SessionUpdate::ToolCallUpdate {
tool_call_id: "tc-002".to_string(),
status: "completed".to_string(),
content: Some(vec![result_block]),
});
assert_eq!(msgs.len(), 3);
match &msgs[0] {
Message::StreamEvent(ev) => assert_eq!(ev.event_type, "tool_call_update"),
other => panic!("expected StreamEvent at index 0, got {other:?}"),
}
match &msgs[1] {
Message::Assistant(m) => match &m.message.content[0] {
ContentBlock::ToolUse(tu) => {
assert_eq!(tu.id, "tc-002");
assert_eq!(tu.name, "Shell");
}
other => panic!("expected ToolUse at index 1, got {other:?}"),
},
other => panic!("expected Assistant at index 1, got {other:?}"),
}
match &msgs[2] {
Message::Assistant(m) => match &m.message.content[0] {
ContentBlock::ToolResult(tr) => {
assert_eq!(tr.tool_use_id, "tc-002");
assert!(!tr.is_error);
assert_eq!(tr.content.len(), 1);
match &tr.content[0] {
ToolResultContent::Text { text } => assert_eq!(text, "output text"),
other => panic!("expected Text result, got {other:?}"),
}
}
other => panic!("expected ToolResult at index 2, got {other:?}"),
},
other => panic!("expected Assistant at index 2, got {other:?}"),
}
}
#[test]
fn test_translate_tool_call_failed_sets_is_error() {
let mut ctx = make_ctx();
ctx.translate(SessionUpdate::ToolCall {
tool_call_id: "tc-003".to_string(),
title: "Shell".to_string(),
kind: "shell".to_string(),
status: "in_progress".to_string(),
locations: vec![],
});
let msgs = ctx.translate(SessionUpdate::ToolCallUpdate {
tool_call_id: "tc-003".to_string(),
status: "failed".to_string(),
content: None,
});
assert_eq!(msgs.len(), 3);
match &msgs[2] {
Message::Assistant(m) => match &m.message.content[0] {
ContentBlock::ToolResult(tr) => assert!(tr.is_error),
other => panic!("expected ToolResult, got {other:?}"),
},
other => panic!("expected Assistant, got {other:?}"),
}
}
#[test]
fn test_translate_plan() {
use crate::wire::WirePlanEntry;
let mut ctx = make_ctx();
let entries = vec![
WirePlanEntry {
content: "Step 1".to_string(),
priority: "high".to_string(),
status: "pending".to_string(),
extra: Value::Object(Default::default()),
},
WirePlanEntry {
content: "Step 2".to_string(),
priority: "normal".to_string(),
status: "pending".to_string(),
extra: Value::Object(Default::default()),
},
];
let msgs = ctx.translate(SessionUpdate::Plan { entries });
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::StreamEvent(ev) => {
assert_eq!(ev.event_type, "plan");
assert!(ev.data.is_array());
assert_eq!(ev.data.as_array().unwrap().len(), 2);
}
other => panic!("expected StreamEvent, got {other:?}"),
}
}
#[test]
fn test_translate_usage_update() {
let mut ctx = make_ctx();
let msgs = ctx.translate(SessionUpdate::UsageUpdate {
cost: Some(0.0042),
size: None,
used: None,
});
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::StreamEvent(ev) => {
assert_eq!(ev.event_type, "usage_update");
assert!((ev.data["cost"].as_f64().unwrap() - 0.0042_f64).abs() < f64::EPSILON);
}
other => panic!("expected StreamEvent, got {other:?}"),
}
}
#[test]
fn test_translate_session_info_update() {
let mut ctx = make_ctx();
let msgs = ctx.translate(SessionUpdate::SessionInfoUpdate {
title: Some("My session".to_string()),
});
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::StreamEvent(ev) => {
assert_eq!(ev.event_type, "session_info");
assert_eq!(ev.data["title"], "My session");
}
other => panic!("expected StreamEvent, got {other:?}"),
}
}
#[test]
fn test_translate_unknown_session_update() {
let mut ctx = make_ctx();
let msgs = ctx.translate(SessionUpdate::Unknown {
kind: "future_event".to_string(),
data: json!({ "foo": 42 }),
});
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::StreamEvent(ev) => {
assert_eq!(ev.event_type, "future_event");
assert_eq!(ev.data["foo"], 42);
assert_eq!(ev.session_id, "sess-test");
}
other => panic!("expected StreamEvent, got {other:?}"),
}
}
#[test]
fn test_translate_user_message_chunk() {
let mut ctx = make_ctx();
let wire = WireContentBlock {
content_type: "text".to_string(),
text: Some("User said this".to_string()),
..Default::default()
};
let msgs = ctx.translate(SessionUpdate::UserMessageChunk { content: wire });
assert_eq!(msgs.len(), 1);
match &msgs[0] {
Message::User(u) => {
assert_eq!(u.session_id, "sess-test");
match &u.message.content[0] {
ContentBlock::Text(t) => assert_eq!(t.text, "User said this"),
other => panic!("expected Text, got {other:?}"),
}
}
other => panic!("expected User, got {other:?}"),
}
}
#[test]
fn test_reset_turn() {
let mut ctx = make_ctx();
ctx.translate(SessionUpdate::AgentMessageChunk {
content: WireContentBlock {
content_type: "text".to_string(),
text: Some("accumulated".to_string()),
..Default::default()
},
});
ctx.translate(SessionUpdate::AgentThoughtChunk {
content: WireContentBlock {
content_type: "text".to_string(),
text: Some("thinking".to_string()),
..Default::default()
},
});
ctx.translate(SessionUpdate::ToolCall {
tool_call_id: "tc-x".to_string(),
title: "T".to_string(),
kind: "k".to_string(),
status: "s".to_string(),
locations: vec![],
});
assert!(!ctx.accumulated_text.is_empty());
assert!(!ctx.accumulated_thinking.is_empty());
assert!(!ctx.tool_calls.is_empty());
ctx.reset_turn();
assert!(ctx.accumulated_text.is_empty(), "text must be cleared");
assert!(ctx.accumulated_thinking.is_empty(), "thinking must be cleared");
assert!(ctx.tool_calls.is_empty(), "tool calls must be cleared");
}
#[test]
fn test_prompt_to_wire() {
let blocks = prompt_to_wire("Say hello");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].content_type, "text");
assert_eq!(blocks[0].text.as_deref(), Some("Say hello"));
}
#[test]
fn test_user_content_to_wire_text() {
let uc = UserContent::Text {
text: "ping".to_string(),
};
let wire = user_content_to_wire(&uc);
assert_eq!(wire.content_type, "text");
assert_eq!(wire.text.as_deref(), Some("ping"));
assert!(wire.data.is_none());
assert!(wire.mime_type.is_none());
}
#[test]
fn test_user_content_to_wire_image_base64() {
let uc = UserContent::image_base64("image/jpeg", "deadbeef==");
let wire = user_content_to_wire(&uc);
assert_eq!(wire.content_type, "image");
assert_eq!(wire.data.as_deref(), Some("deadbeef=="));
assert_eq!(wire.mime_type.as_deref(), Some("image/jpeg"));
assert!(wire.text.is_none());
}
#[test]
fn test_user_content_to_wire_image_url() {
let uc = UserContent::image_url("https://example.com/photo.png");
let wire = user_content_to_wire(&uc);
assert_eq!(wire.content_type, "image");
assert_eq!(wire.uri.as_deref(), Some("https://example.com/photo.png"));
assert!(wire.data.is_none());
}
}