From f5e7ec943cb4459ada3c1fafc4d7b6c2f17da913 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 25 Jan 2026 00:19:37 -0800 Subject: [PATCH] feat: add universal agent schema --- engine/packages/error/Cargo.toml | 2 +- .../universal-agent-schema/Cargo.toml | 10 + .../universal-agent-schema/src/lib.rs | 1181 +++++++++++++++++ 3 files changed, 1192 insertions(+), 1 deletion(-) create mode 100644 engine/packages/universal-agent-schema/Cargo.toml create mode 100644 engine/packages/universal-agent-schema/src/lib.rs diff --git a/engine/packages/error/Cargo.toml b/engine/packages/error/Cargo.toml index b38c8f9..297ac59 100644 --- a/engine/packages/error/Cargo.toml +++ b/engine/packages/error/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "error" +name = "sandbox-daemon-error" version = "0.1.0" edition = "2021" diff --git a/engine/packages/universal-agent-schema/Cargo.toml b/engine/packages/universal-agent-schema/Cargo.toml new file mode 100644 index 0000000..e47b4b0 --- /dev/null +++ b/engine/packages/universal-agent-schema/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "sandbox-daemon-universal-agent-schema" +version = "0.1.0" +edition = "2021" + +[dependencies] +sandbox-daemon-agent-schema = { path = "../agent-schema" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" diff --git a/engine/packages/universal-agent-schema/src/lib.rs b/engine/packages/universal-agent-schema/src/lib.rs new file mode 100644 index 0000000..c438f49 --- /dev/null +++ b/engine/packages/universal-agent-schema/src/lib.rs @@ -0,0 +1,1181 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use thiserror::Error; + +pub use sandbox_daemon_agent_schema::{amp, claude, codex, opencode}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UniversalEvent { + pub id: u64, + pub timestamp: String, + pub session_id: String, + pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option, + pub data: UniversalEventData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[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 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Started { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CrashInfo { + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UniversalMessageParsed { + pub role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub metadata: Map, + pub parts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UniversalMessage { + Parsed(UniversalMessageParsed), + Unparsed { + raw: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum UniversalMessagePart { + Text { text: String }, + ToolCall { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + name: String, + input: Value, + }, + ToolResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + output: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + is_error: Option, + }, + FunctionCall { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + arguments: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw: Option, + }, + FunctionResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + result: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + is_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw: Option, + }, + File { + source: AttachmentSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw: Option, + }, + Image { + source: AttachmentSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + alt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + raw: Option, + }, + Error { message: String }, + Unknown { raw: Value }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[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, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuestionRequest { + pub id: String, + pub session_id: String, + pub questions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuestionInfo { + pub question: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub header: Option, + pub options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multi_select: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuestionOption { + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuestionToolRef { + pub message_id: String, + pub call_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequest { + pub id: String, + pub session_id: String, + pub permission: String, + pub patterns: Vec, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub metadata: Map, + pub always: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[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 for ConversionError { + fn from(err: serde_json::Error) -> Self { + Self::Serde(err.to_string()) + } +} + +#[derive(Debug, Clone)] +pub struct EventConversion { + pub data: UniversalEventData, + pub agent_session_id: Option, +} + +impl EventConversion { + pub fn new(data: UniversalEventData) -> Self { + Self { + data, + agent_session_id: None, + } + } + + pub fn with_session(mut self, session_id: Option) -> Self { + self.agent_session_id = session_id; + 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) -> UniversalMessage { + UniversalMessage::Parsed(UniversalMessageParsed { + role: role.to_string(), + id: None, + metadata: Map::new(), + parts, + }) +} + +fn message_parts_to_text(parts: &[UniversalMessagePart]) -> Option { + let mut text = String::new(); + for part in parts { + if let UniversalMessagePart::Text { text: part_text } = part { + if !text.is_empty() { + text.push_str("\n"); + } + text.push_str(part_text); + } + } + if text.is_empty() { None } else { Some(text) } +} + +fn extract_message_from_value(value: &Value) -> Option { + if let Some(message) = value.get("message").and_then(Value::as_str) { + return Some(message.to_string()); + } + 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 +} + +pub mod convert_opencode { + use super::*; + + pub fn event_to_universal(event: &opencode::Event) -> EventConversion { + match event { + opencode::Event::MessageUpdated(updated) => { + let (message, session_id) = message_from_opencode(&updated.properties.info); + EventConversion::new(UniversalEventData::Message { message }) + .with_session(session_id) + } + opencode::Event::MessagePartUpdated(updated) => { + let (message, session_id) = part_to_message(&updated.properties.part); + EventConversion::new(UniversalEventData::Message { message }) + .with_session(session_id) + } + opencode::Event::QuestionAsked(asked) => { + let question = question_request_from_opencode(&asked.properties); + EventConversion::new(UniversalEventData::QuestionAsked { question_asked: question }) + .with_session(Some(String::from(asked.properties.session_id.clone()))) + } + opencode::Event::PermissionAsked(asked) => { + let permission = permission_request_from_opencode(&asked.properties); + EventConversion::new(UniversalEventData::PermissionAsked { permission_asked: permission }) + .with_session(Some(String::from(asked.properties.session_id.clone()))) + } + opencode::Event::SessionCreated(created) => { + let details = serde_json::to_value(created).ok(); + let started = Started { + message: Some("session.created".to_string()), + details, + }; + EventConversion::new(UniversalEventData::Started { started }) + } + opencode::Event::SessionError(error) => { + let message = extract_message_from_value(&serde_json::to_value(&error.properties).unwrap_or(Value::Null)) + .unwrap_or_else(|| "opencode session error".to_string()); + let crash = CrashInfo { + message, + kind: Some("session.error".to_string()), + details: serde_json::to_value(&error.properties).ok(), + }; + EventConversion::new(UniversalEventData::Error { error: crash }) + .with_session(error.properties.session_id.clone()) + } + _ => EventConversion::new(UniversalEventData::Unknown { + raw: serde_json::to_value(event).unwrap_or(Value::Null), + }), + } + } + + pub fn universal_event_to_opencode(event: &UniversalEventData) -> Result { + match event { + UniversalEventData::QuestionAsked { question_asked } => { + let properties = question_request_to_opencode(question_asked)?; + Ok(opencode::Event::QuestionAsked(opencode::EventQuestionAsked { + properties, + type_: "question.asked".to_string(), + })) + } + UniversalEventData::PermissionAsked { permission_asked } => { + let properties = permission_request_to_opencode(permission_asked)?; + Ok(opencode::Event::PermissionAsked(opencode::EventPermissionAsked { + properties, + type_: "permission.asked".to_string(), + })) + } + _ => Err(ConversionError::Unsupported("opencode event")), + } + } + + pub fn universal_message_to_parts( + message: &UniversalMessage, + ) -> Result, ConversionError> { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let mut parts = Vec::new(); + for part in &parsed.parts { + match part { + UniversalMessagePart::Text { text } => { + parts.push(opencode::TextPartInput { + id: None, + ignored: None, + metadata: Map::new(), + synthetic: None, + text: text.clone(), + time: None, + type_: "text".to_string(), + }); + } + _ => return Err(ConversionError::Unsupported("non-text part")), + } + } + if parts.is_empty() { + return Err(ConversionError::MissingField("parts")); + } + Ok(parts) + } + + pub fn text_part_input_to_universal(part: &opencode::TextPartInput) -> UniversalMessage { + let mut metadata = part.metadata.clone(); + if let Some(id) = &part.id { + metadata.insert("partId".to_string(), Value::String(id.clone())); + } + UniversalMessage::Parsed(UniversalMessageParsed { + role: "user".to_string(), + id: None, + metadata, + parts: vec![UniversalMessagePart::Text { + text: part.text.clone(), + }], + }) + } + + fn message_from_opencode(message: &opencode::Message) -> (UniversalMessage, Option) { + match message { + opencode::Message::UserMessage(user) => { + let mut metadata = Map::new(); + metadata.insert("agent".to_string(), Value::String(user.agent.clone())); + let parsed = UniversalMessageParsed { + role: user.role.clone(), + id: Some(user.id.clone()), + metadata, + parts: Vec::new(), + }; + ( + UniversalMessage::Parsed(parsed), + Some(user.session_id.clone()), + ) + } + opencode::Message::AssistantMessage(assistant) => { + let mut metadata = Map::new(); + metadata.insert("agent".to_string(), Value::String(assistant.agent.clone())); + let parsed = UniversalMessageParsed { + role: assistant.role.clone(), + id: Some(assistant.id.clone()), + metadata, + parts: Vec::new(), + }; + ( + UniversalMessage::Parsed(parsed), + Some(assistant.session_id.clone()), + ) + } + } + } + + fn part_to_message(part: &opencode::Part) -> (UniversalMessage, Option) { + match part { + opencode::Part::Variant0(text_part) => { + let mut metadata = Map::new(); + metadata.insert("messageId".to_string(), Value::String(text_part.message_id.clone())); + metadata.insert("partId".to_string(), Value::String(text_part.id.clone())); + let parsed = UniversalMessageParsed { + role: "assistant".to_string(), + id: Some(text_part.message_id.clone()), + metadata, + parts: vec![UniversalMessagePart::Text { + text: text_part.text.clone(), + }], + }; + ( + UniversalMessage::Parsed(parsed), + Some(text_part.session_id.clone()), + ) + } + opencode::Part::Variant4(tool_part) => { + let mut metadata = Map::new(); + metadata.insert("messageId".to_string(), Value::String(tool_part.message_id.clone())); + metadata.insert("partId".to_string(), Value::String(tool_part.id.clone())); + let parts = tool_state_to_parts(&tool_part); + let parsed = UniversalMessageParsed { + role: "assistant".to_string(), + id: Some(tool_part.message_id.clone()), + metadata, + parts, + }; + ( + UniversalMessage::Parsed(parsed), + Some(tool_part.session_id.clone()), + ) + } + _ => ( + UniversalMessage::Unparsed { + raw: serde_json::to_value(part).unwrap_or(Value::Null), + error: Some("unsupported opencode part".to_string()), + }, + None, + ), + } + } + + fn tool_state_to_parts(tool_part: &opencode::ToolPart) -> Vec { + match &tool_part.state { + opencode::ToolState::Pending(state) => vec![UniversalMessagePart::ToolCall { + id: Some(tool_part.call_id.clone()), + name: tool_part.tool.clone(), + input: Value::Object(state.input.clone()), + }], + opencode::ToolState::Running(state) => vec![UniversalMessagePart::ToolCall { + id: Some(tool_part.call_id.clone()), + name: tool_part.tool.clone(), + input: Value::Object(state.input.clone()), + }], + opencode::ToolState::Completed(state) => vec![UniversalMessagePart::ToolResult { + id: Some(tool_part.call_id.clone()), + name: Some(tool_part.tool.clone()), + output: Value::String(state.output.clone()), + is_error: Some(false), + }], + opencode::ToolState::Error(state) => vec![UniversalMessagePart::ToolResult { + id: Some(tool_part.call_id.clone()), + name: Some(tool_part.tool.clone()), + output: Value::String(state.error.clone()), + is_error: Some(true), + }], + } + } + + fn question_request_from_opencode(request: &opencode::QuestionRequest) -> QuestionRequest { + QuestionRequest { + id: String::from(request.id.clone()), + session_id: String::from(request.session_id.clone()), + questions: request + .questions + .iter() + .map(|question| QuestionInfo { + question: question.question.clone(), + header: Some(question.header.clone()), + options: question + .options + .iter() + .map(|opt| QuestionOption { + label: opt.label.clone(), + description: Some(opt.description.clone()), + }) + .collect(), + multi_select: question.multiple, + custom: question.custom, + }) + .collect(), + tool: request.tool.as_ref().map(|tool| QuestionToolRef { + message_id: tool.message_id.clone(), + call_id: tool.call_id.clone(), + }), + } + } + + fn permission_request_from_opencode(request: &opencode::PermissionRequest) -> PermissionRequest { + PermissionRequest { + id: String::from(request.id.clone()), + session_id: String::from(request.session_id.clone()), + permission: request.permission.clone(), + patterns: request.patterns.clone(), + metadata: request.metadata.clone(), + always: request.always.clone(), + tool: request.tool.as_ref().map(|tool| PermissionToolRef { + message_id: tool.message_id.clone(), + call_id: tool.call_id.clone(), + }), + } + } + + fn question_request_to_opencode(request: &QuestionRequest) -> Result { + let id = opencode::QuestionRequestId::try_from(request.id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + let session_id = opencode::QuestionRequestSessionId::try_from(request.session_id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + let questions = request + .questions + .iter() + .map(|question| opencode::QuestionInfo { + question: question.question.clone(), + header: question + .header + .clone() + .unwrap_or_else(|| "Question".to_string()), + options: question + .options + .iter() + .map(|opt| opencode::QuestionOption { + label: opt.label.clone(), + description: opt.description.clone().unwrap_or_default(), + }) + .collect(), + multiple: question.multi_select, + custom: question.custom, + }) + .collect(); + + Ok(opencode::QuestionRequest { + id, + session_id, + questions, + tool: request.tool.as_ref().map(|tool| opencode::QuestionRequestTool { + message_id: tool.message_id.clone(), + call_id: tool.call_id.clone(), + }), + }) + } + + fn permission_request_to_opencode( + request: &PermissionRequest, + ) -> Result { + let id = opencode::PermissionRequestId::try_from(request.id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + let session_id = opencode::PermissionRequestSessionId::try_from(request.session_id.as_str()) + .map_err(|err| ConversionError::InvalidValue(err.to_string()))?; + Ok(opencode::PermissionRequest { + id, + session_id, + permission: request.permission.clone(), + patterns: request.patterns.clone(), + metadata: request.metadata.clone(), + always: request.always.clone(), + tool: request.tool.as_ref().map(|tool| opencode::PermissionRequestTool { + message_id: tool.message_id.clone(), + call_id: tool.call_id.clone(), + }), + }) + } +} + +pub mod convert_codex { + use super::*; + + pub fn event_to_universal(event: &codex::ThreadEvent) -> EventConversion { + match event.type_ { + codex::ThreadEventType::ThreadCreated | codex::ThreadEventType::ThreadUpdated => { + let started = Started { + message: Some(event.type_.to_string()), + details: serde_json::to_value(event).ok(), + }; + EventConversion::new(UniversalEventData::Started { started }) + .with_session(event.thread_id.clone()) + } + codex::ThreadEventType::ItemCreated | codex::ThreadEventType::ItemUpdated => { + if let Some(item) = &event.item { + let message = thread_item_to_message(item); + EventConversion::new(UniversalEventData::Message { message }) + .with_session(event.thread_id.clone()) + } else { + EventConversion::new(UniversalEventData::Unknown { + raw: serde_json::to_value(event).unwrap_or(Value::Null), + }) + } + } + codex::ThreadEventType::Error => { + let message = extract_message_from_value(&Value::Object(event.error.clone())) + .unwrap_or_else(|| "codex error".to_string()); + let crash = CrashInfo { + message, + kind: Some("error".to_string()), + details: Some(Value::Object(event.error.clone())), + }; + EventConversion::new(UniversalEventData::Error { error: crash }) + .with_session(event.thread_id.clone()) + } + } + } + + pub fn universal_event_to_codex(event: &UniversalEventData) -> Result { + match event { + UniversalEventData::Message { message } => { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let id = parsed.id.clone().ok_or(ConversionError::MissingField("message.id"))?; + let content = message_parts_to_text(&parsed.parts) + .ok_or(ConversionError::MissingField("text part"))?; + let role = match parsed.role.as_str() { + "user" => Some(codex::ThreadItemRole::User), + "assistant" => Some(codex::ThreadItemRole::Assistant), + "system" => Some(codex::ThreadItemRole::System), + _ => None, + }; + let item = codex::ThreadItem { + content: Some(codex::ThreadItemContent::Variant0(content)), + id, + role, + status: None, + type_: codex::ThreadItemType::Message, + }; + Ok(codex::ThreadEvent { + error: Map::new(), + item: Some(item), + thread_id: None, + type_: codex::ThreadEventType::ItemCreated, + }) + } + _ => Err(ConversionError::Unsupported("codex event")), + } + } + + pub fn message_to_universal(message: &codex::Message) -> UniversalMessage { + UniversalMessage::Parsed(UniversalMessageParsed { + role: message.role.to_string(), + id: None, + metadata: Map::new(), + parts: vec![UniversalMessagePart::Text { + text: message.content.clone(), + }], + }) + } + + pub fn universal_message_to_message( + message: &UniversalMessage, + ) -> Result { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let content = message_parts_to_text(&parsed.parts) + .ok_or(ConversionError::MissingField("text part"))?; + Ok(codex::Message { + role: match parsed.role.as_str() { + "user" => codex::MessageRole::User, + "assistant" => codex::MessageRole::Assistant, + "system" => codex::MessageRole::System, + _ => codex::MessageRole::User, + }, + content, + }) + } + + fn thread_item_to_message(item: &codex::ThreadItem) -> UniversalMessage { + let mut metadata = Map::new(); + metadata.insert("itemType".to_string(), Value::String(item.type_.to_string())); + let role = item + .role + .as_ref() + .map(|role| role.to_string()) + .unwrap_or_else(|| "assistant".to_string()); + let parts = match item.type_ { + codex::ThreadItemType::Message => message_parts_from_codex_content(&item.content), + codex::ThreadItemType::FunctionCall => { + vec![function_call_part_from_codex(item)] + } + codex::ThreadItemType::FunctionResult => { + vec![function_result_part_from_codex(item)] + } + }; + UniversalMessage::Parsed(UniversalMessageParsed { + role, + id: Some(item.id.clone()), + metadata, + parts, + }) + } + + fn message_parts_from_codex_content( + content: &Option, + ) -> Vec { + match content { + Some(codex::ThreadItemContent::Variant0(text)) => { + vec![UniversalMessagePart::Text { text: text.clone() }] + } + Some(codex::ThreadItemContent::Variant1(raw)) => { + vec![UniversalMessagePart::Unknown { + raw: serde_json::to_value(raw).unwrap_or(Value::Null), + }] + } + None => Vec::new(), + } + } + + fn function_call_part_from_codex(item: &codex::ThreadItem) -> UniversalMessagePart { + let raw = thread_item_content_to_value(&item.content); + let name = extract_object_field(&raw, "name"); + let arguments = extract_object_value(&raw, "arguments").unwrap_or_else(|| raw.clone()); + UniversalMessagePart::FunctionCall { + id: Some(item.id.clone()), + name, + arguments, + raw: Some(raw), + } + } + + fn function_result_part_from_codex(item: &codex::ThreadItem) -> UniversalMessagePart { + let raw = thread_item_content_to_value(&item.content); + let name = extract_object_field(&raw, "name"); + let result = extract_object_value(&raw, "result") + .or_else(|| extract_object_value(&raw, "output")) + .or_else(|| extract_object_value(&raw, "content")) + .unwrap_or_else(|| raw.clone()); + UniversalMessagePart::FunctionResult { + id: Some(item.id.clone()), + name, + result, + is_error: None, + raw: Some(raw), + } + } + + fn thread_item_content_to_value(content: &Option) -> Value { + match content { + Some(codex::ThreadItemContent::Variant0(text)) => Value::String(text.clone()), + Some(codex::ThreadItemContent::Variant1(raw)) => { + Value::Array(raw.iter().cloned().map(Value::Object).collect()) + } + None => Value::Null, + } + } + + fn extract_object_field(raw: &Value, field: &str) -> Option { + extract_object_value(raw, field) + .and_then(|value| value.as_str().map(|s| s.to_string())) + } + + fn extract_object_value(raw: &Value, field: &str) -> Option { + match raw { + Value::Object(map) => map.get(field).cloned(), + Value::Array(values) => values + .first() + .and_then(|value| value.as_object()) + .and_then(|map| map.get(field).cloned()), + _ => None, + } + } +} + +pub mod convert_amp { + use super::*; + + pub fn event_to_universal(event: &::StreamJsonMessage) -> EventConversion { + match event.type_ { + amp::StreamJsonMessageType::Message => { + let text = event.content.clone().unwrap_or_default(); + let mut message = message_from_text("assistant", text); + if let UniversalMessage::Parsed(parsed) = &mut message { + parsed.id = event.id.clone(); + } + EventConversion::new(UniversalEventData::Message { message }) + } + amp::StreamJsonMessageType::ToolCall => { + let tool_call = event.tool_call.as_ref(); + let part = if let Some(tool_call) = tool_call { + let input = match &tool_call.arguments { + amp::ToolCallArguments::Variant0(text) => Value::String(text.clone()), + amp::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), + }; + UniversalMessagePart::ToolCall { + id: Some(tool_call.id.clone()), + name: tool_call.name.clone(), + input, + } + } else { + UniversalMessagePart::Unknown { raw: Value::Null } + }; + let mut message = message_from_parts("assistant", vec![part]); + if let UniversalMessage::Parsed(parsed) = &mut message { + parsed.id = event.id.clone(); + } + EventConversion::new(UniversalEventData::Message { message }) + } + amp::StreamJsonMessageType::ToolResult => { + let output = event + .content + .clone() + .map(Value::String) + .unwrap_or(Value::Null); + let part = UniversalMessagePart::ToolResult { + id: event.id.clone(), + name: None, + output, + is_error: None, + }; + let message = message_from_parts("tool", vec![part]); + EventConversion::new(UniversalEventData::Message { message }) + } + amp::StreamJsonMessageType::Error => { + let message = event.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 }) + } + amp::StreamJsonMessageType::Done => EventConversion::new(UniversalEventData::Unknown { + raw: serde_json::to_value(event).unwrap_or(Value::Null), + }), + } + } + + pub fn universal_event_to_amp(event: &UniversalEventData) -> Result { + match event { + UniversalEventData::Message { message } => { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let content = message_parts_to_text(&parsed.parts) + .ok_or(ConversionError::MissingField("text part"))?; + Ok(amp::StreamJsonMessage { + content: Some(content), + error: None, + id: parsed.id.clone(), + tool_call: None, + type_: amp::StreamJsonMessageType::Message, + }) + } + _ => Err(ConversionError::Unsupported("amp event")), + } + } + + pub fn message_to_universal(message: &::Message) -> UniversalMessage { + let mut parts = vec![UniversalMessagePart::Text { + text: message.content.clone(), + }]; + for call in &message.tool_calls { + let input = match &call.arguments { + amp::ToolCallArguments::Variant0(text) => Value::String(text.clone()), + amp::ToolCallArguments::Variant1(map) => Value::Object(map.clone()), + }; + parts.push(UniversalMessagePart::ToolCall { + id: Some(call.id.clone()), + name: call.name.clone(), + input, + }); + } + UniversalMessage::Parsed(UniversalMessageParsed { + role: message.role.to_string(), + id: None, + metadata: Map::new(), + parts, + }) + } + + pub fn universal_message_to_message( + message: &UniversalMessage, + ) -> Result { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let content = message_parts_to_text(&parsed.parts) + .ok_or(ConversionError::MissingField("text part"))?; + Ok(amp::Message { + role: match parsed.role.as_str() { + "user" => amp::MessageRole::User, + "assistant" => amp::MessageRole::Assistant, + "system" => amp::MessageRole::System, + _ => amp::MessageRole::User, + }, + content, + tool_calls: vec![], + }) + } +} + +pub mod convert_claude { + use super::*; + + pub fn event_to_universal_with_session( + event: &Value, + session_id: String, + ) -> EventConversion { + let event_type = event.get("type").and_then(Value::as_str).unwrap_or(""); + match event_type { + "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 { + match event { + UniversalEventData::Message { message } => { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + let text = message_parts_to_text(&parsed.parts) + .ok_or(ConversionError::MissingField("text part"))?; + 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 { + let parsed = match message { + UniversalMessage::Parsed(parsed) => parsed, + UniversalMessage::Unparsed { .. } => { + return Err(ConversionError::Unsupported("unparsed message")) + } + }; + message_parts_to_text(&parsed.parts) + .ok_or(ConversionError::MissingField("text part")) + } + + fn assistant_event_to_universal(event: &Value) -> EventConversion { + 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(); + 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 { + text: text.to_string(), + }); + } + } + "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, + }); + } + } + _ => parts.push(UniversalMessagePart::Unknown { raw: block }), + } + } + let message = UniversalMessage::Parsed(UniversalMessageParsed { + role: "assistant".to_string(), + id: None, + metadata: Map::new(), + parts, + }); + EventConversion::new(UniversalEventData::Message { message }) + } + + fn tool_use_event_to_universal(event: &Value, session_id: String) -> EventConversion { + let tool_use = event.get("tool_use"); + let name = tool_use + .and_then(|tool| tool.get("name")) + .and_then(Value::as_str) + .unwrap_or(""); + let input = tool_use + .and_then(|tool| tool.get("input")) + .cloned() + .unwrap_or(Value::Null); + let id = tool_use + .and_then(|tool| tool.get("id")) + .and_then(Value::as_str) + .map(|s| s.to_string()); + + 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 message = message_from_parts( + "assistant", + vec![UniversalMessagePart::ToolCall { + id, + name: name.to_string(), + input, + }], + ); + EventConversion::new(UniversalEventData::Message { message }) + } + + fn tool_result_event_to_universal(event: &Value) -> EventConversion { + 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()); + + let message = message_from_parts( + "tool", + vec![UniversalMessagePart::ToolResult { + id, + name: None, + output, + is_error, + }], + ); + EventConversion::new(UniversalEventData::Message { message }) + } + + fn result_event_to_universal(event: &Value) -> 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) + } + + fn question_from_claude_input( + input: &Value, + tool_id: Option, + session_id: String, + ) -> Option { + 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 + .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 }) + }) + .collect::>() + })?; + 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, + }) + } +}