feat: sync universal schema and sdk updates

This commit is contained in:
Nathan Flurry 2026-01-27 02:52:25 -08:00
parent 79bb441287
commit f5d1a6383d
56 changed files with 6800 additions and 3974 deletions

View file

@ -1,155 +1,161 @@
use crate::{
message_from_parts,
message_from_text,
text_only_from_parts,
ConversionError,
CrashInfo,
EventConversion,
UniversalEventData,
UniversalMessage,
UniversalMessageParsed,
UniversalMessagePart,
};
use crate::amp as schema;
use serde_json::{Map, Value};
use std::sync::atomic::{AtomicU64, Ordering};
pub fn event_to_universal(event: &schema::StreamJsonMessage) -> EventConversion {
let schema::StreamJsonMessage {
content,
error,
id,
tool_call,
type_,
} = event;
match type_ {
use serde_json::Value;
use crate::amp as schema;
use crate::{
ContentPart,
ErrorData,
EventConversion,
ItemDeltaData,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
SessionEndedData,
SessionEndReason,
TerminatedBy,
UniversalEventData,
UniversalEventType,
UniversalItem,
};
static TEMP_ID: AtomicU64 = AtomicU64::new(1);
fn next_temp_id(prefix: &str) -> String {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
format!("{prefix}_{id}")
}
pub fn event_to_universal(event: &schema::StreamJsonMessage) -> Result<Vec<EventConversion>, String> {
let mut events = Vec::new();
match event.type_ {
schema::StreamJsonMessageType::Message => {
let text = content.clone().unwrap_or_default();
let mut message = message_from_text("assistant", text);
if let UniversalMessage::Parsed(parsed) = &mut message {
parsed.id = id.clone();
}
EventConversion::new(UniversalEventData::Message { message })
let text = event.content.clone().unwrap_or_default();
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_message"),
native_item_id: event.id.clone(),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Text { text: text.clone() }],
status: ItemStatus::Completed,
};
events.extend(message_events(item, text));
}
schema::StreamJsonMessageType::ToolCall => {
let tool_call = tool_call.as_ref();
let part = if let Some(tool_call) = tool_call {
let schema::ToolCall { arguments, id, name } = tool_call;
let input = match arguments {
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()),
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()),
let tool_call = event.tool_call.clone();
let (name, arguments, call_id) = if let Some(call) = tool_call {
let arguments = match call.arguments {
schema::ToolCallArguments::Variant0(text) => text,
schema::ToolCallArguments::Variant1(map) => {
serde_json::to_string(&Value::Object(map)).unwrap_or_else(|_| "{}".to_string())
}
};
UniversalMessagePart::ToolCall {
id: Some(id.clone()),
name: name.clone(),
input,
}
(call.name, arguments, call.id)
} else {
UniversalMessagePart::Unknown { raw: Value::Null }
("unknown".to_string(), "{}".to_string(), next_temp_id("tmp_amp_tool"))
};
let mut message = message_from_parts("assistant", vec![part]);
if let UniversalMessage::Parsed(parsed) = &mut message {
parsed.id = id.clone();
}
EventConversion::new(UniversalEventData::Message { message })
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_tool_call"),
native_item_id: Some(call_id.clone()),
parent_id: None,
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name,
arguments,
call_id,
}],
status: ItemStatus::Completed,
};
events.extend(item_events(item));
}
schema::StreamJsonMessageType::ToolResult => {
let output = content
let output = event.content.clone().unwrap_or_default();
let call_id = event
.id
.clone()
.map(Value::String)
.unwrap_or(Value::Null);
let part = UniversalMessagePart::ToolResult {
id: id.clone(),
name: None,
output,
is_error: None,
.unwrap_or_else(|| next_temp_id("tmp_amp_tool"));
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_tool_result"),
native_item_id: Some(call_id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id,
output,
}],
status: ItemStatus::Completed,
};
let message = message_from_parts("tool", vec![part]);
EventConversion::new(UniversalEventData::Message { message })
events.extend(item_events(item));
}
schema::StreamJsonMessageType::Error => {
let message = error.clone().unwrap_or_else(|| "amp error".to_string());
let crash = CrashInfo {
message,
kind: Some("amp".to_string()),
details: serde_json::to_value(event).ok(),
};
EventConversion::new(UniversalEventData::Error { error: crash })
let message = event.error.clone().unwrap_or_else(|| "amp error".to_string());
events.push(EventConversion::new(
UniversalEventType::Error,
UniversalEventData::Error(ErrorData {
message,
code: Some("amp".to_string()),
details: serde_json::to_value(event).ok(),
}),
));
}
schema::StreamJsonMessageType::Done => {
events.push(
EventConversion::new(
UniversalEventType::SessionEnded,
UniversalEventData::SessionEnded(SessionEndedData {
reason: SessionEndReason::Completed,
terminated_by: TerminatedBy::Agent,
}),
)
.with_raw(serde_json::to_value(event).ok()),
);
}
schema::StreamJsonMessageType::Done => EventConversion::new(UniversalEventData::Unknown {
raw: serde_json::to_value(event).unwrap_or(Value::Null),
}),
}
for conversion in &mut events {
conversion.raw = serde_json::to_value(event).ok();
}
Ok(events)
}
pub fn universal_event_to_amp(event: &UniversalEventData) -> Result<schema::StreamJsonMessage, ConversionError> {
match event {
UniversalEventData::Message { message } => {
let parsed = match message {
UniversalMessage::Parsed(parsed) => parsed,
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
};
let content = text_only_from_parts(&parsed.parts)?;
Ok(schema::StreamJsonMessage {
content: Some(content),
error: None,
id: parsed.id.clone(),
tool_call: None,
type_: schema::StreamJsonMessageType::Message,
})
}
_ => Err(ConversionError::Unsupported("amp event")),
}
fn item_events(item: UniversalItem) -> Vec<EventConversion> {
vec![EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)]
}
pub fn message_to_universal(message: &schema::Message) -> UniversalMessage {
let schema::Message {
role,
content,
tool_calls,
} = message;
let mut parts = vec![UniversalMessagePart::Text {
text: content.clone(),
}];
for call in tool_calls {
let schema::ToolCall { arguments, id, name } = call;
let input = match arguments {
schema::ToolCallArguments::Variant0(text) => Value::String(text.clone()),
schema::ToolCallArguments::Variant1(map) => Value::Object(map.clone()),
};
parts.push(UniversalMessagePart::ToolCall {
id: Some(id.clone()),
name: name.clone(),
input,
});
fn message_events(item: UniversalItem, delta: String) -> Vec<EventConversion> {
let mut events = Vec::new();
let mut started = item.clone();
started.status = ItemStatus::InProgress;
events.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: started }),
)
.synthetic(),
);
if !delta.is_empty() {
events.push(
EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: item.item_id.clone(),
native_item_id: item.native_item_id.clone(),
delta,
}),
)
.synthetic(),
);
}
UniversalMessage::Parsed(UniversalMessageParsed {
role: role.to_string(),
id: None,
metadata: Map::new(),
parts,
})
}
pub fn universal_message_to_message(
message: &UniversalMessage,
) -> Result<schema::Message, ConversionError> {
let parsed = match message {
UniversalMessage::Parsed(parsed) => parsed,
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
};
let content = text_only_from_parts(&parsed.parts)?;
Ok(schema::Message {
role: match parsed.role.as_str() {
"user" => schema::MessageRole::User,
"assistant" => schema::MessageRole::Assistant,
"system" => schema::MessageRole::System,
_ => schema::MessageRole::User,
},
content,
tool_calls: vec![],
})
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
events
}

View file

@ -1,94 +1,76 @@
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
use crate::{
message_from_parts,
message_from_text,
text_only_from_parts,
ConversionError,
ContentPart,
EventConversion,
QuestionInfo,
QuestionOption,
QuestionRequest,
Started,
ItemEventData,
ItemKind,
ItemRole,
ItemStatus,
QuestionEventData,
QuestionStatus,
SessionStartedData,
UniversalEventData,
UniversalMessage,
UniversalMessageParsed,
UniversalMessagePart,
UniversalEventType,
UniversalItem,
};
use serde_json::{Map, Value};
static TEMP_ID: AtomicU64 = AtomicU64::new(1);
fn next_temp_id(prefix: &str) -> String {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
format!("{prefix}_{id}")
}
pub fn event_to_universal_with_session(
event: &Value,
session_id: String,
) -> EventConversion {
) -> Result<Vec<EventConversion>, String> {
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
match event_type {
"system" => system_event_to_universal(event),
let mut conversions = match event_type {
"system" => vec![system_event_to_universal(event)],
"assistant" => assistant_event_to_universal(event),
"tool_use" => tool_use_event_to_universal(event, session_id),
"tool_result" => tool_result_event_to_universal(event),
"result" => result_event_to_universal(event),
_ => EventConversion::new(UniversalEventData::Unknown { raw: event.clone() }),
}
}
pub fn universal_event_to_claude(event: &UniversalEventData) -> Result<Value, ConversionError> {
match event {
UniversalEventData::Message { message } => {
let parsed = match message {
UniversalMessage::Parsed(parsed) => parsed,
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
};
let text = text_only_from_parts(&parsed.parts)?;
Ok(Value::Object(Map::from_iter([
("type".to_string(), Value::String("assistant".to_string())),
(
"message".to_string(),
Value::Object(Map::from_iter([(
"content".to_string(),
Value::Array(vec![Value::Object(Map::from_iter([(
"type".to_string(),
Value::String("text".to_string()),
), (
"text".to_string(),
Value::String(text),
)]))]),
)])),
),
])))
}
_ => Err(ConversionError::Unsupported("claude event")),
}
}
pub fn prompt_to_universal(prompt: &str) -> UniversalMessage {
message_from_text("user", prompt.to_string())
}
pub fn universal_message_to_prompt(message: &UniversalMessage) -> Result<String, ConversionError> {
let parsed = match message {
UniversalMessage::Parsed(parsed) => parsed,
UniversalMessage::Unparsed { .. } => {
return Err(ConversionError::Unsupported("unparsed message"))
}
_ => return Err(format!("unsupported Claude event type: {event_type}")),
};
text_only_from_parts(&parsed.parts)
for conversion in &mut conversions {
conversion.raw = Some(event.clone());
}
Ok(conversions)
}
fn assistant_event_to_universal(event: &Value) -> EventConversion {
fn system_event_to_universal(event: &Value) -> EventConversion {
let data = SessionStartedData {
metadata: Some(event.clone()),
};
EventConversion::new(UniversalEventType::SessionStarted, UniversalEventData::SessionStarted(data))
.with_raw(Some(event.clone()))
}
fn assistant_event_to_universal(event: &Value) -> Vec<EventConversion> {
let mut conversions = Vec::new();
let content = event
.get("message")
.and_then(|msg| msg.get("content"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut parts = Vec::new();
let message_id = next_temp_id("tmp_claude_message");
let mut message_parts = Vec::new();
for block in content {
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(Value::as_str) {
parts.push(UniversalMessagePart::Text {
message_parts.push(ContentPart::Text {
text: text.to_string(),
});
}
@ -96,39 +78,50 @@ fn assistant_event_to_universal(event: &Value) -> EventConversion {
"tool_use" => {
if let Some(name) = block.get("name").and_then(Value::as_str) {
let input = block.get("input").cloned().unwrap_or(Value::Null);
let id = block.get("id").and_then(Value::as_str).map(|s| s.to_string());
parts.push(UniversalMessagePart::ToolCall {
id,
name: name.to_string(),
input,
});
let call_id = block
.get("id")
.and_then(Value::as_str)
.map(|s| s.to_string())
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
let tool_item = UniversalItem {
item_id: next_temp_id("tmp_claude_tool_item"),
native_item_id: Some(call_id.clone()),
parent_id: Some(message_id.clone()),
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: name.to_string(),
arguments,
call_id,
}],
status: ItemStatus::Completed,
};
conversions.extend(item_events(tool_item, true));
}
}
_ => parts.push(UniversalMessagePart::Unknown { raw: block }),
_ => {
message_parts.push(ContentPart::Json { json: block });
}
}
}
let message = UniversalMessage::Parsed(UniversalMessageParsed {
role: "assistant".to_string(),
id: None,
metadata: Map::new(),
parts,
});
EventConversion::new(UniversalEventData::Message { message })
}
fn system_event_to_universal(event: &Value) -> EventConversion {
let subtype = event
.get("subtype")
.and_then(Value::as_str)
.unwrap_or("system");
let started = Started {
message: Some(format!("system.{subtype}")),
details: Some(event.clone()),
let message_item = UniversalItem {
item_id: message_id,
native_item_id: None,
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: message_parts.clone(),
status: ItemStatus::Completed,
};
EventConversion::new(UniversalEventData::Started { started })
conversions.extend(message_events(message_item, message_parts, true));
conversions
}
fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConversion {
fn tool_use_event_to_universal(event: &Value, session_id: String) -> Vec<EventConversion> {
let mut conversions = Vec::new();
let tool_use = event.get("tool_use");
let name = tool_use
.and_then(|tool| tool.get("name"))
@ -141,113 +134,219 @@ fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConver
let id = tool_use
.and_then(|tool| tool.get("id"))
.and_then(Value::as_str)
.map(|s| s.to_string());
.map(|s| s.to_string())
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
if name == "AskUserQuestion" {
if let Some(question) =
question_from_claude_input(&input, id.clone(), session_id.clone())
{
return EventConversion::new(UniversalEventData::QuestionAsked {
question_asked: question,
});
let is_question_tool = matches!(
name,
"AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
);
let has_question_payload = input.get("questions").is_some();
if is_question_tool || has_question_payload {
if let Some(question) = question_from_claude_input(&input, id.clone()) {
conversions.push(
EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(question),
)
.with_raw(Some(event.clone())),
);
}
}
let message = message_from_parts(
"assistant",
vec![UniversalMessagePart::ToolCall {
id,
let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
let tool_item = UniversalItem {
item_id: next_temp_id("tmp_claude_tool_item"),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: name.to_string(),
input,
arguments,
call_id: id,
}],
);
EventConversion::new(UniversalEventData::Message { message })
status: ItemStatus::Completed,
};
conversions.extend(item_events(tool_item, true));
if conversions.is_empty() {
let data = QuestionEventData {
question_id: next_temp_id("tmp_claude_question"),
prompt: "".to_string(),
options: Vec::new(),
response: None,
status: QuestionStatus::Requested,
};
conversions.push(
EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(data),
)
.with_raw(Some(Value::String(format!(
"unexpected question payload for session {session_id}"
)))),
);
}
conversions
}
fn tool_result_event_to_universal(event: &Value) -> EventConversion {
fn tool_result_event_to_universal(event: &Value) -> Vec<EventConversion> {
let mut conversions = Vec::new();
let tool_result = event.get("tool_result");
let output = tool_result
.and_then(|tool| tool.get("content"))
.cloned()
.unwrap_or(Value::Null);
let is_error = tool_result
.and_then(|tool| tool.get("is_error"))
.and_then(Value::as_bool);
let id = tool_result
.and_then(|tool| tool.get("id"))
.and_then(Value::as_str)
.map(|s| s.to_string());
.map(|s| s.to_string())
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
let output_text = serde_json::to_string(&output).unwrap_or_else(|_| "".to_string());
let message = message_from_parts(
"tool",
vec![UniversalMessagePart::ToolResult {
id,
name: None,
output,
is_error,
let tool_item = UniversalItem {
item_id: next_temp_id("tmp_claude_tool_result"),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id: id,
output: output_text,
}],
);
EventConversion::new(UniversalEventData::Message { message })
status: ItemStatus::Completed,
};
conversions.extend(item_events(tool_item, true));
conversions
}
fn result_event_to_universal(event: &Value) -> EventConversion {
fn result_event_to_universal(event: &Value) -> Vec<EventConversion> {
let result_text = event
.get("result")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let session_id = event
.get("session_id")
.and_then(Value::as_str)
.map(|s| s.to_string());
let message = message_from_text("assistant", result_text);
EventConversion::new(UniversalEventData::Message { message }).with_session(session_id)
let message_item = UniversalItem {
item_id: next_temp_id("tmp_claude_result"),
native_item_id: None,
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Text { text: result_text.clone() }],
status: ItemStatus::Completed,
};
message_events(message_item, vec![ContentPart::Text { text: result_text }], true)
}
fn question_from_claude_input(
input: &Value,
tool_id: Option<String>,
session_id: String,
) -> Option<QuestionRequest> {
let questions = input.get("questions").and_then(Value::as_array)?;
let mut parsed_questions = Vec::new();
for question in questions {
let question_text = question.get("question")?.as_str()?.to_string();
let header = question
.get("header")
.and_then(Value::as_str)
.map(|s| s.to_string());
let multi_select = question
.get("multiSelect")
.and_then(Value::as_bool);
let options = question
fn item_events(item: UniversalItem, synthetic_start: bool) -> Vec<EventConversion> {
let mut events = Vec::new();
if synthetic_start {
let mut started_item = item.clone();
started_item.status = ItemStatus::InProgress;
events.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: started_item }),
)
.synthetic(),
);
}
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
events
}
fn message_events(item: UniversalItem, parts: Vec<ContentPart>, synthetic_start: bool) -> Vec<EventConversion> {
let mut events = Vec::new();
if synthetic_start {
let mut started_item = item.clone();
started_item.status = ItemStatus::InProgress;
events.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: started_item }),
)
.synthetic(),
);
}
let mut delta_text = String::new();
for part in &parts {
if let ContentPart::Text { text } = part {
delta_text.push_str(text);
}
}
if !delta_text.is_empty() {
events.push(
EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(crate::ItemDeltaData {
item_id: item.item_id.clone(),
native_item_id: item.native_item_id.clone(),
delta: delta_text,
}),
)
.synthetic(),
);
}
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
events
}
fn question_from_claude_input(input: &Value, tool_id: String) -> Option<QuestionEventData> {
if let Some(questions) = input.get("questions").and_then(Value::as_array) {
if let Some(first) = questions.first() {
let prompt = first.get("question")?.as_str()?.to_string();
let options = first
.get("options")
.and_then(Value::as_array)
.map(|opts| {
opts.iter()
.filter_map(|opt| opt.get("label").and_then(Value::as_str))
.map(|label| label.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
return Some(QuestionEventData {
question_id: tool_id,
prompt,
options,
response: None,
status: QuestionStatus::Requested,
});
}
}
let prompt = input
.get("question")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if prompt.is_empty() {
return None;
}
Some(QuestionEventData {
question_id: tool_id,
prompt,
options: input
.get("options")
.and_then(Value::as_array)
.map(|options| {
options
.iter()
.filter_map(|option| {
let label = option.get("label")?.as_str()?.to_string();
let description = option
.get("description")
.and_then(Value::as_str)
.map(|s| s.to_string());
Some(QuestionOption { label, description })
})
.map(|opts| {
opts.iter()
.filter_map(Value::as_str)
.map(|s| s.to_string())
.collect::<Vec<_>>()
})?;
parsed_questions.push(QuestionInfo {
question: question_text,
header,
options,
multi_select,
custom: None,
});
}
Some(QuestionRequest {
id: tool_id.unwrap_or_else(|| "claude-question".to_string()),
session_id,
questions: parsed_questions,
tool: None,
})
.unwrap_or_default(),
response: None,
status: QuestionStatus::Requested,
})
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,6 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use serde_json::Value;
use schemars::JsonSchema;
use thiserror::Error;
use utoipa::ToSchema;
pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode};
@ -11,318 +10,282 @@ pub mod agents;
pub use agents::{amp as convert_amp, claude as convert_claude, codex as convert_codex, opencode as convert_opencode};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UniversalEvent {
pub id: u64,
pub timestamp: String,
pub event_id: String,
pub sequence: u64,
pub time: String,
pub session_id: String,
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
pub native_session_id: Option<String>,
pub synthetic: bool,
pub source: EventSource,
#[serde(rename = "type")]
pub event_type: UniversalEventType,
pub data: UniversalEventData,
pub raw: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EventSource {
Agent,
Daemon,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
pub enum UniversalEventType {
#[serde(rename = "session.started")]
SessionStarted,
#[serde(rename = "session.ended")]
SessionEnded,
#[serde(rename = "item.started")]
ItemStarted,
#[serde(rename = "item.delta")]
ItemDelta,
#[serde(rename = "item.completed")]
ItemCompleted,
#[serde(rename = "error")]
Error,
#[serde(rename = "permission.requested")]
PermissionRequested,
#[serde(rename = "permission.resolved")]
PermissionResolved,
#[serde(rename = "question.requested")]
QuestionRequested,
#[serde(rename = "question.resolved")]
QuestionResolved,
#[serde(rename = "agent.unparsed")]
AgentUnparsed,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(untagged)]
pub enum UniversalEventData {
Message { message: UniversalMessage },
Started { started: Started },
Error { error: CrashInfo },
QuestionAsked {
#[serde(rename = "questionAsked")]
question_asked: QuestionRequest,
},
PermissionAsked {
#[serde(rename = "permissionAsked")]
permission_asked: PermissionRequest,
},
Unknown { raw: Value },
SessionStarted(SessionStartedData),
SessionEnded(SessionEndedData),
Item(ItemEventData),
ItemDelta(ItemDeltaData),
Error(ErrorData),
Permission(PermissionEventData),
Question(QuestionEventData),
AgentUnparsed(AgentUnparsedData),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct Started {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
pub struct SessionStartedData {
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CrashInfo {
pub struct SessionEndedData {
pub reason: SessionEndReason,
pub terminated_by: TerminatedBy,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SessionEndReason {
Completed,
Error,
Terminated,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TerminatedBy {
Agent,
Daemon,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ItemEventData {
pub item: UniversalItem,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ItemDeltaData {
pub item_id: String,
pub native_item_id: Option<String>,
pub delta: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ErrorData {
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub details: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct UniversalMessageParsed {
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
pub parts: Vec<UniversalMessagePart>,
pub struct AgentUnparsedData {
pub error: String,
pub location: String,
pub raw_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(untagged)]
pub enum UniversalMessage {
Parsed(UniversalMessageParsed),
Unparsed {
raw: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
pub struct PermissionEventData {
pub permission_id: String,
pub action: String,
pub status: PermissionStatus,
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PermissionStatus {
Requested,
Approved,
Denied,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct QuestionEventData {
pub question_id: String,
pub prompt: String,
pub options: Vec<String>,
pub response: Option<String>,
pub status: QuestionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum QuestionStatus {
Requested,
Answered,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct UniversalItem {
pub item_id: String,
pub native_item_id: Option<String>,
pub parent_id: Option<String>,
pub kind: ItemKind,
pub role: Option<ItemRole>,
pub content: Vec<ContentPart>,
pub status: ItemStatus,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemKind {
Message,
ToolCall,
ToolResult,
System,
Status,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemRole {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemStatus {
InProgress,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum UniversalMessagePart {
pub enum ContentPart {
Text { text: String },
ToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
name: String,
input: Value,
},
ToolResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
output: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
FunctionCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
arguments: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
FunctionResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
result: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
File {
source: AttachmentSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
Image {
source: AttachmentSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
alt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw: Option<Value>,
},
Error { message: String },
Unknown { raw: Value },
Json { json: Value },
ToolCall { name: String, arguments: String, call_id: String },
ToolResult { call_id: String, output: String },
FileRef { path: String, action: FileAction, diff: Option<String> },
Reasoning { text: String, visibility: ReasoningVisibility },
Image { path: String, mime: Option<String> },
Status { label: String, detail: Option<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AttachmentSource {
Path { path: String },
Url { url: String },
Data {
data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
encoding: Option<String>,
},
#[serde(rename_all = "snake_case")]
pub enum FileAction {
Read,
Write,
Patch,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionRequest {
pub id: String,
pub session_id: String,
pub questions: Vec<QuestionInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<QuestionToolRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionInfo {
pub question: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
pub options: Vec<QuestionOption>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multi_select: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionOption {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionToolRef {
pub message_id: String,
pub call_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequest {
pub id: String,
pub session_id: String,
pub permission: String,
pub patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
pub always: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<PermissionToolRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PermissionToolRef {
pub message_id: String,
pub call_id: String,
}
#[derive(Debug, Error)]
pub enum ConversionError {
#[error("unsupported conversion: {0}")]
Unsupported(&'static str),
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("invalid value: {0}")]
InvalidValue(String),
#[error("serde error: {0}")]
Serde(String),
}
impl From<serde_json::Error> for ConversionError {
fn from(err: serde_json::Error) -> Self {
Self::Serde(err.to_string())
}
#[serde(rename_all = "snake_case")]
pub enum ReasoningVisibility {
Public,
Private,
}
#[derive(Debug, Clone)]
pub struct EventConversion {
pub event_type: UniversalEventType,
pub data: UniversalEventData,
pub agent_session_id: Option<String>,
pub native_session_id: Option<String>,
pub source: EventSource,
pub synthetic: bool,
pub raw: Option<Value>,
}
impl EventConversion {
pub fn new(data: UniversalEventData) -> Self {
pub fn new(event_type: UniversalEventType, data: UniversalEventData) -> Self {
Self {
event_type,
data,
agent_session_id: None,
native_session_id: None,
source: EventSource::Agent,
synthetic: false,
raw: None,
}
}
pub fn with_session(mut self, session_id: Option<String>) -> Self {
self.agent_session_id = session_id;
pub fn with_native_session(mut self, session_id: Option<String>) -> Self {
self.native_session_id = session_id;
self
}
pub fn with_raw(mut self, raw: Option<Value>) -> Self {
self.raw = raw;
self
}
pub fn synthetic(mut self) -> Self {
self.synthetic = true;
self.source = EventSource::Daemon;
self
}
pub fn with_source(mut self, source: EventSource) -> Self {
self.source = source;
self
}
}
fn message_from_text(role: &str, text: String) -> UniversalMessage {
UniversalMessage::Parsed(UniversalMessageParsed {
role: role.to_string(),
id: None,
metadata: Map::new(),
parts: vec![UniversalMessagePart::Text { text }],
})
}
fn message_from_parts(role: &str, parts: Vec<UniversalMessagePart>) -> UniversalMessage {
UniversalMessage::Parsed(UniversalMessageParsed {
role: role.to_string(),
id: None,
metadata: Map::new(),
parts,
})
}
fn text_only_from_parts(parts: &[UniversalMessagePart]) -> Result<String, ConversionError> {
let mut text = String::new();
for part in parts {
match part {
UniversalMessagePart::Text { text: part_text } => {
if !text.is_empty() {
text.push_str("\n");
}
text.push_str(part_text);
}
UniversalMessagePart::ToolCall { .. } => {
return Err(ConversionError::Unsupported("tool call part"))
}
UniversalMessagePart::ToolResult { .. } => {
return Err(ConversionError::Unsupported("tool result part"))
}
UniversalMessagePart::FunctionCall { .. } => {
return Err(ConversionError::Unsupported("function call part"))
}
UniversalMessagePart::FunctionResult { .. } => {
return Err(ConversionError::Unsupported("function result part"))
}
UniversalMessagePart::File { .. } => {
return Err(ConversionError::Unsupported("file part"))
}
UniversalMessagePart::Image { .. } => {
return Err(ConversionError::Unsupported("image part"))
}
UniversalMessagePart::Error { .. } => {
return Err(ConversionError::Unsupported("error part"))
}
UniversalMessagePart::Unknown { .. } => {
return Err(ConversionError::Unsupported("unknown part"))
}
}
}
if text.is_empty() {
Err(ConversionError::MissingField("text part"))
} else {
Ok(text)
pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem {
UniversalItem {
item_id: String::new(),
native_item_id: None,
parent_id: None,
kind: ItemKind::Message,
role: Some(role),
content: vec![ContentPart::Text { text }],
status: ItemStatus::Completed,
}
}
fn extract_message_from_value(value: &Value) -> Option<String> {
if let Some(message) = value.get("message").and_then(Value::as_str) {
return Some(message.to_string());
pub fn item_from_parts(role: ItemRole, kind: ItemKind, parts: Vec<ContentPart>) -> UniversalItem {
UniversalItem {
item_id: String::new(),
native_item_id: None,
parent_id: None,
kind,
role: Some(role),
content: parts,
status: ItemStatus::Completed,
}
if let Some(message) = value.get("error").and_then(|v| v.get("message")).and_then(Value::as_str) {
return Some(message.to_string());
}
if let Some(message) = value.get("data").and_then(|v| v.get("message")).and_then(Value::as_str) {
return Some(message.to_string());
}
None
}