This commit is contained in:
Nathan Flurry 2026-02-09 18:53:00 -08:00
parent a33b1323ff
commit 2ba630c180
264 changed files with 18559 additions and 51021 deletions

View file

@ -1,16 +0,0 @@
[package]
name = "sandbox-agent-universal-agent-schema"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
description.workspace = true
repository.workspace = true
[dependencies]
sandbox-agent-extracted-agent-schemas.workspace = true
serde.workspace = true
serde_json.workspace = true
schemars.workspace = true
thiserror.workspace = true
utoipa.workspace = true

View file

@ -1,227 +0,0 @@
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
use crate::amp as schema;
use crate::{
turn_ended_event, ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData,
ItemKind, ItemRole, ItemStatus, SessionEndReason, SessionEndedData, 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_ {
// System init message - contains metadata like cwd, tools, session_id
// We skip this as it's not a user-facing event
schema::StreamJsonMessageType::System => {}
// User message - extract content from the nested message field
schema::StreamJsonMessageType::User => {
if !event.message.is_empty() {
let text = event
.message
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_user"),
native_item_id: event.session_id.clone(),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::User),
content: vec![ContentPart::Text { text: text.clone() }],
status: ItemStatus::Completed,
};
events.extend(message_events(item, text));
}
}
// Assistant message - extract content from the nested message field
schema::StreamJsonMessageType::Assistant => {
if !event.message.is_empty() {
let text = event
.message
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let item = UniversalItem {
item_id: next_temp_id("tmp_amp_assistant"),
native_item_id: event.session_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));
}
}
// Result message - signals completion
schema::StreamJsonMessageType::Result => {
events.push(turn_ended_event(None, None).synthetic());
events.push(
EventConversion::new(
UniversalEventType::SessionEnded,
UniversalEventData::SessionEnded(SessionEndedData {
reason: if event.is_error.unwrap_or(false) {
SessionEndReason::Error
} else {
SessionEndReason::Completed
},
terminated_by: TerminatedBy::Agent,
message: event.result.clone(),
exit_code: None,
stderr: None,
}),
)
.with_raw(serde_json::to_value(event).ok()),
);
}
schema::StreamJsonMessageType::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 = 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())
}
};
(call.name, arguments, call.id)
} else {
(
"unknown".to_string(),
"{}".to_string(),
next_temp_id("tmp_amp_tool"),
)
};
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 = event.content.clone().unwrap_or_default();
let call_id = event
.id
.clone()
.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,
};
events.extend(item_events(item));
}
schema::StreamJsonMessageType::Error => {
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(turn_ended_event(None, None).synthetic());
events.push(
EventConversion::new(
UniversalEventType::SessionEnded,
UniversalEventData::SessionEnded(SessionEndedData {
reason: SessionEndReason::Completed,
terminated_by: TerminatedBy::Agent,
message: None,
exit_code: None,
stderr: None,
}),
)
.with_raw(serde_json::to_value(event).ok()),
);
}
}
for conversion in &mut events {
conversion.raw = serde_json::to_value(event).ok();
}
Ok(events)
}
fn item_events(item: UniversalItem) -> Vec<EventConversion> {
vec![EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)]
}
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(),
);
}
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
events
}

View file

@ -1,524 +0,0 @@
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::Value;
use crate::{
turn_ended_event, ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind,
ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus,
SessionStartedData, 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_with_session(
event: &Value,
session_id: String,
) -> Result<Vec<EventConversion>, String> {
let event_type = event.get("type").and_then(Value::as_str).unwrap_or("");
let mut conversions = match event_type {
"system" => vec![system_event_to_universal(event)],
"user" => user_event_to_universal(event),
"assistant" => assistant_event_to_universal(event, &session_id),
"tool_use" => tool_use_event_to_universal(event, &session_id),
"tool_result" => tool_result_event_to_universal(event),
"result" => result_event_to_universal(event, &session_id),
"stream_event" => stream_event_to_universal(event),
"control_request" => control_request_to_universal(event)?,
"control_response" | "keep_alive" | "update_environment_variables" => Vec::new(),
_ => return Err(format!("unsupported Claude event type: {event_type}")),
};
for conversion in &mut conversions {
conversion.raw = Some(event.clone());
}
Ok(conversions)
}
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, session_id: &str) -> 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();
// Use session-based native_item_id so `result` event can reference the same item
let native_message_id = claude_message_id(event, session_id);
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) {
message_parts.push(ContentPart::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 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 is_exit_plan_mode = matches!(
name,
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
);
let is_question_tool = matches!(
name,
"AskUserQuestion"
| "ask_user_question"
| "askUserQuestion"
| "ask-user-question"
) || is_exit_plan_mode;
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, call_id.clone())
{
conversions.push(
EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(question),
)
.with_raw(Some(event.clone())),
);
} else if is_exit_plan_mode {
conversions.push(
EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(QuestionEventData {
question_id: call_id.clone(),
prompt: "Approve plan execution?".to_string(),
options: vec!["approve".to_string(), "reject".to_string()],
response: None,
status: QuestionStatus::Requested,
}),
)
.with_raw(Some(event.clone())),
);
}
}
let arguments =
serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
let tool_item = UniversalItem {
item_id: String::new(),
native_item_id: Some(call_id.clone()),
parent_id: Some(native_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));
}
}
_ => {
message_parts.push(ContentPart::Json { json: block });
}
}
}
// `assistant` event emits item.started + item.delta only (in-progress state)
// The `result` event will emit item.completed to finalize
let message_item = UniversalItem {
item_id: String::new(),
native_item_id: Some(native_message_id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: message_parts.clone(),
status: ItemStatus::InProgress,
};
conversions.extend(message_started_events(message_item));
conversions
}
fn user_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();
for block in content {
let block_type = block.get("type").and_then(Value::as_str).unwrap_or("");
if block_type != "tool_result" {
continue;
}
let tool_use_id = block
.get("tool_use_id")
.or_else(|| block.get("toolUseId"))
.and_then(Value::as_str)
.map(|s| s.to_string())
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
let output = block.get("content").cloned().unwrap_or(Value::Null);
let output_text = serde_json::to_string(&output).unwrap_or_default();
let tool_item = UniversalItem {
item_id: next_temp_id("tmp_claude_tool_result"),
native_item_id: Some(tool_use_id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id: tool_use_id,
output: output_text,
}],
status: ItemStatus::Completed,
};
conversions.extend(item_events(tool_item, true));
}
conversions
}
fn tool_use_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConversion> {
let mut conversions = Vec::new();
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())
.unwrap_or_else(|| next_temp_id("tmp_claude_tool"));
let is_exit_plan_mode = matches!(
name,
"ExitPlanMode" | "exit_plan_mode" | "exitPlanMode" | "exit-plan-mode"
);
let is_question_tool = matches!(
name,
"AskUserQuestion" | "ask_user_question" | "askUserQuestion" | "ask-user-question"
) || is_exit_plan_mode;
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())),
);
} else if is_exit_plan_mode {
conversions.push(
EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(QuestionEventData {
question_id: id.clone(),
prompt: "Approve plan execution?".to_string(),
options: vec!["approve".to_string(), "reject".to_string()],
response: None,
status: QuestionStatus::Requested,
}),
)
.with_raw(Some(event.clone())),
);
}
}
let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string());
let tool_item = UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: name.to_string(),
arguments,
call_id: id,
}],
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) -> 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 id = tool_result
.and_then(|tool| tool.get("id"))
.and_then(Value::as_str)
.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 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,
}],
status: ItemStatus::Completed,
};
conversions.extend(item_events(tool_item, true));
conversions
}
fn stream_event_to_universal(event: &Value) -> Vec<EventConversion> {
let mut conversions = Vec::new();
let Some(raw_event) = event.get("event").and_then(Value::as_object) else {
return conversions;
};
let event_type = raw_event.get("type").and_then(Value::as_str).unwrap_or("");
if event_type != "content_block_delta" {
return conversions;
}
let delta_text = raw_event
.get("delta")
.and_then(|delta| delta.get("text"))
.and_then(Value::as_str)
.unwrap_or("");
if delta_text.is_empty() {
return conversions;
}
conversions.push(EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: None,
delta: delta_text.to_string(),
}),
));
conversions
}
fn control_request_to_universal(event: &Value) -> Result<Vec<EventConversion>, String> {
let request_id = event
.get("request_id")
.and_then(Value::as_str)
.ok_or_else(|| "missing request_id".to_string())?;
let request = event
.get("request")
.and_then(Value::as_object)
.ok_or_else(|| "missing request".to_string())?;
let subtype = request.get("subtype").and_then(Value::as_str).unwrap_or("");
if subtype != "can_use_tool" {
return Err(format!(
"unsupported Claude control_request subtype: {subtype}"
));
}
let tool_name = request
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("unknown");
let input = request.get("input").cloned().unwrap_or(Value::Null);
let permission_suggestions = request
.get("permission_suggestions")
.cloned()
.unwrap_or(Value::Null);
let blocked_path = request.get("blocked_path").cloned().unwrap_or(Value::Null);
let metadata = serde_json::json!({
"toolName": tool_name,
"input": input,
"permissionSuggestions": permission_suggestions,
"blockedPath": blocked_path,
});
let permission = PermissionEventData {
permission_id: request_id.to_string(),
action: tool_name.to_string(),
status: PermissionStatus::Requested,
metadata: Some(metadata),
};
Ok(vec![EventConversion::new(
UniversalEventType::PermissionRequested,
UniversalEventData::Permission(permission),
)])
}
fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConversion> {
// The `result` event completes the message started by `assistant`.
// Use the same native_item_id so they link to the same universal item.
let native_message_id = claude_message_id(event, session_id);
let result_text = event
.get("result")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let message_item = UniversalItem {
item_id: String::new(),
native_item_id: Some(native_message_id),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Text { text: result_text }],
status: ItemStatus::Completed,
};
vec![
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item: message_item }),
),
turn_ended_event(None, None).synthetic(),
]
}
fn claude_message_id(event: &Value, session_id: &str) -> String {
event
.get("message")
.and_then(|message| message.get("id"))
.and_then(Value::as_str)
.or_else(|| event.get("message_id").and_then(Value::as_str))
.or_else(|| event.get("messageId").and_then(Value::as_str))
.map(|id| id.to_string())
.unwrap_or_else(|| format!("{session_id}_message"))
}
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
}
/// Emits item.started + item.delta only (for `assistant` event).
/// The item.completed will come from the `result` event.
fn message_started_events(item: UniversalItem) -> Vec<EventConversion> {
let mut events = Vec::new();
// Emit item.started (in-progress)
events.push(EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: item.clone() }),
));
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(|opts| {
opts.iter()
.filter_map(Value::as_str)
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default(),
response: None,
status: QuestionStatus::Requested,
})
}

View file

@ -1,528 +0,0 @@
use serde_json::Value;
use crate::codex as schema;
use crate::{
ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole,
ItemStatus, ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData,
TerminatedBy, TurnEventData, TurnPhase, UniversalEventData, UniversalEventType, UniversalItem,
};
/// Convert a Codex ServerNotification to universal events.
pub fn notification_to_universal(
notification: &schema::ServerNotification,
) -> Result<Vec<EventConversion>, String> {
let raw = serde_json::to_value(notification).ok();
match notification {
schema::ServerNotification::ThreadStarted(params) => {
let data = SessionStartedData {
metadata: serde_json::to_value(&params.thread).ok(),
};
Ok(vec![EventConversion::new(
UniversalEventType::SessionStarted,
UniversalEventData::SessionStarted(data),
)
.with_native_session(Some(params.thread.id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ThreadCompacted(params) => Ok(vec![status_event(
"thread.compacted",
serde_json::to_string(params).ok(),
Some(params.thread_id.clone()),
raw,
)]),
schema::ServerNotification::ThreadTokenUsageUpdated(params) => Ok(vec![status_event(
"thread.token_usage.updated",
serde_json::to_string(params).ok(),
Some(params.thread_id.clone()),
raw,
)]),
schema::ServerNotification::TurnStarted(params) => Ok(vec![EventConversion::new(
UniversalEventType::TurnStarted,
UniversalEventData::Turn(TurnEventData {
phase: TurnPhase::Started,
turn_id: Some(params.turn.id.clone()),
metadata: serde_json::to_value(&params.turn).ok(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)]),
schema::ServerNotification::TurnCompleted(params) => Ok(vec![EventConversion::new(
UniversalEventType::TurnEnded,
UniversalEventData::Turn(TurnEventData {
phase: TurnPhase::Ended,
turn_id: Some(params.turn.id.clone()),
metadata: serde_json::to_value(&params.turn).ok(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)]),
schema::ServerNotification::TurnDiffUpdated(params) => Ok(vec![status_event(
"turn.diff.updated",
serde_json::to_string(params).ok(),
Some(params.thread_id.clone()),
raw,
)]),
schema::ServerNotification::TurnPlanUpdated(params) => Ok(vec![status_event(
"turn.plan.updated",
serde_json::to_string(params).ok(),
Some(params.thread_id.clone()),
raw,
)]),
schema::ServerNotification::ItemStarted(params) => {
let item = thread_item_to_item(&params.item, ItemStatus::InProgress);
Ok(vec![EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item }),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemCompleted(params) => {
let item = thread_item_to_item(&params.item, ItemStatus::Completed);
Ok(vec![EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemAgentMessageDelta(params) => {
Ok(vec![EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(params.item_id.clone()),
delta: params.delta.clone(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemReasoningTextDelta(params) => {
Ok(vec![EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(params.item_id.clone()),
delta: params.delta.clone(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemReasoningSummaryTextDelta(params) => {
Ok(vec![EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(params.item_id.clone()),
delta: params.delta.clone(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemCommandExecutionOutputDelta(params) => {
Ok(vec![EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(params.item_id.clone()),
delta: params.delta.clone(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemFileChangeOutputDelta(params) => {
Ok(vec![EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(params.item_id.clone()),
delta: params.delta.clone(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemCommandExecutionTerminalInteraction(params) => {
Ok(vec![EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(params.item_id.clone()),
delta: params.stdin.clone(),
}),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::ItemMcpToolCallProgress(params) => Ok(vec![status_event(
"mcp.progress",
serde_json::to_string(params).ok(),
Some(params.thread_id.clone()),
raw,
)]),
schema::ServerNotification::ItemReasoningSummaryPartAdded(params) => {
Ok(vec![status_event(
"reasoning.summary.part_added",
serde_json::to_string(params).ok(),
Some(params.thread_id.clone()),
raw,
)])
}
schema::ServerNotification::Error(params) => {
let data = ErrorData {
message: params.error.message.clone(),
code: None,
details: serde_json::to_value(params).ok(),
};
Ok(vec![EventConversion::new(
UniversalEventType::Error,
UniversalEventData::Error(data),
)
.with_native_session(Some(params.thread_id.clone()))
.with_raw(raw)])
}
schema::ServerNotification::RawResponseItemCompleted(params) => Ok(vec![status_event(
"raw.item.completed",
serde_json::to_string(params).ok(),
Some(params.thread_id.clone()),
raw,
)]),
schema::ServerNotification::AccountUpdated(_)
| schema::ServerNotification::AccountRateLimitsUpdated(_)
| schema::ServerNotification::AccountLoginCompleted(_)
| schema::ServerNotification::McpServerOauthLoginCompleted(_)
| schema::ServerNotification::AuthStatusChange(_)
| schema::ServerNotification::LoginChatGptComplete(_)
| schema::ServerNotification::SessionConfigured(_)
| schema::ServerNotification::DeprecationNotice(_)
| schema::ServerNotification::ConfigWarning(_)
| schema::ServerNotification::WindowsWorldWritableWarning(_) => Ok(vec![status_event(
"notice",
serde_json::to_string(notification).ok(),
None,
raw,
)]),
}
}
fn thread_item_to_item(item: &schema::ThreadItem, status: ItemStatus) -> UniversalItem {
match item {
schema::ThreadItem::UserMessage { content, id } => UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::User),
content: content.iter().map(user_input_to_content).collect(),
status,
},
schema::ThreadItem::AgentMessage { id, text } => UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Text { text: text.clone() }],
status,
},
schema::ThreadItem::Reasoning {
content,
id,
summary,
} => {
let mut parts = Vec::new();
for line in content {
parts.push(ContentPart::Reasoning {
text: line.clone(),
visibility: ReasoningVisibility::Private,
});
}
for line in summary {
parts.push(ContentPart::Reasoning {
text: line.clone(),
visibility: ReasoningVisibility::Public,
});
}
UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: parts,
status,
}
}
schema::ThreadItem::CommandExecution {
aggregated_output,
command,
cwd,
id,
status: exec_status,
..
} => {
let mut parts = Vec::new();
if let Some(output) = aggregated_output {
parts.push(ContentPart::ToolResult {
call_id: id.clone(),
output: output.clone(),
});
}
parts.push(ContentPart::Json {
json: serde_json::json!({
"command": command,
"cwd": cwd,
"status": format!("{:?}", exec_status)
}),
});
UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: parts,
status,
}
}
schema::ThreadItem::FileChange {
changes,
id,
status: file_status,
} => UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::Json {
json: serde_json::json!({
"changes": changes,
"status": format!("{:?}", file_status)
}),
}],
status,
},
schema::ThreadItem::McpToolCall {
arguments,
error,
id,
result,
server,
status: tool_status,
tool,
..
} => {
let mut parts = Vec::new();
if matches!(tool_status, schema::McpToolCallStatus::Completed) {
let output = result
.as_ref()
.and_then(|value| serde_json::to_string(value).ok())
.unwrap_or_else(|| "".to_string());
parts.push(ContentPart::ToolResult {
call_id: id.clone(),
output,
});
} else if matches!(tool_status, schema::McpToolCallStatus::Failed) {
let output = error
.as_ref()
.map(|value| value.message.clone())
.unwrap_or_else(|| "".to_string());
parts.push(ContentPart::ToolResult {
call_id: id.clone(),
output,
});
} else {
let arguments =
serde_json::to_string(arguments).unwrap_or_else(|_| "{}".to_string());
parts.push(ContentPart::ToolCall {
name: format!("{server}.{tool}"),
arguments,
call_id: id.clone(),
});
}
let kind = if matches!(tool_status, schema::McpToolCallStatus::Completed)
|| matches!(tool_status, schema::McpToolCallStatus::Failed)
{
ItemKind::ToolResult
} else {
ItemKind::ToolCall
};
let role = if kind == ItemKind::ToolResult {
ItemRole::Tool
} else {
ItemRole::Assistant
};
UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind,
role: Some(role),
content: parts,
status,
}
}
schema::ThreadItem::CollabAgentToolCall {
id,
prompt,
tool,
status: tool_status,
..
} => {
let mut parts = Vec::new();
if matches!(tool_status, schema::CollabAgentToolCallStatus::Completed) {
parts.push(ContentPart::ToolResult {
call_id: id.clone(),
output: prompt.clone().unwrap_or_default(),
});
} else {
parts.push(ContentPart::ToolCall {
name: tool.to_string(),
arguments: prompt.clone().unwrap_or_default(),
call_id: id.clone(),
});
}
let kind = if matches!(tool_status, schema::CollabAgentToolCallStatus::Completed) {
ItemKind::ToolResult
} else {
ItemKind::ToolCall
};
let role = if kind == ItemKind::ToolResult {
ItemRole::Tool
} else {
ItemRole::Assistant
};
UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind,
role: Some(role),
content: parts,
status,
}
}
schema::ThreadItem::WebSearch { id, query } => UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: "web_search".to_string(),
arguments: query.clone(),
call_id: id.clone(),
}],
status,
},
schema::ThreadItem::ImageView { id, path } => UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Image {
path: path.clone(),
mime: None,
}],
status,
},
schema::ThreadItem::EnteredReviewMode { id, review } => {
status_item_internal(id, "review.entered", Some(review.clone()), status)
}
schema::ThreadItem::ExitedReviewMode { id, review } => {
status_item_internal(id, "review.exited", Some(review.clone()), status)
}
}
}
fn status_item(label: &str, detail: Option<String>) -> UniversalItem {
UniversalItem {
item_id: String::new(),
native_item_id: None,
parent_id: None,
kind: ItemKind::Status,
role: Some(ItemRole::System),
content: vec![ContentPart::Status {
label: label.to_string(),
detail,
}],
status: ItemStatus::Completed,
}
}
fn status_item_internal(
id: &str,
label: &str,
detail: Option<String>,
status: ItemStatus,
) -> UniversalItem {
UniversalItem {
item_id: String::new(),
native_item_id: Some(id.to_string()),
parent_id: None,
kind: ItemKind::Status,
role: Some(ItemRole::System),
content: vec![ContentPart::Status {
label: label.to_string(),
detail,
}],
status,
}
}
fn status_event(
label: &str,
detail: Option<String>,
session_id: Option<String>,
raw: Option<Value>,
) -> EventConversion {
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData {
item: status_item(label, detail),
}),
)
.with_native_session(session_id)
.with_raw(raw)
}
fn user_input_to_content(input: &schema::UserInput) -> ContentPart {
match input {
schema::UserInput::Text { text, .. } => ContentPart::Text { text: text.clone() },
schema::UserInput::Image { image_url } => ContentPart::Image {
path: image_url.clone(),
mime: None,
},
schema::UserInput::LocalImage { path } => ContentPart::Image {
path: path.clone(),
mime: None,
},
schema::UserInput::Skill { name, path } => ContentPart::Json {
json: serde_json::json!({
"type": "skill",
"name": name,
"path": path,
}),
},
}
}
pub fn session_ended_event(thread_id: &str, reason: SessionEndReason) -> EventConversion {
EventConversion::new(
UniversalEventType::SessionEnded,
UniversalEventData::SessionEnded(SessionEndedData {
reason,
terminated_by: TerminatedBy::Agent,
message: None,
exit_code: None,
stderr: None,
}),
)
.with_native_session(Some(thread_id.to_string()))
}

View file

@ -1,5 +0,0 @@
pub mod amp;
pub mod claude;
pub mod codex;
pub mod opencode;
pub mod pi;

View file

@ -1,621 +0,0 @@
use serde_json::Value;
use crate::opencode as schema;
use crate::{
ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility,
SessionStartedData, TurnEventData, TurnPhase, UniversalEventData, UniversalEventType,
UniversalItem,
};
pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, String> {
let raw = serde_json::to_value(event).ok();
match event {
schema::Event::MessageUpdated(updated) => {
let schema::EventMessageUpdated {
properties,
type_: _,
} = updated;
let schema::EventMessageUpdatedProperties { info } = properties;
let (mut item, completed, session_id) = message_to_item(info);
item.status = if completed {
ItemStatus::Completed
} else {
ItemStatus::InProgress
};
let event_type = if completed {
UniversalEventType::ItemCompleted
} else {
UniversalEventType::ItemStarted
};
let conversion =
EventConversion::new(event_type, UniversalEventData::Item(ItemEventData { item }))
.with_native_session(session_id)
.with_raw(raw);
Ok(vec![conversion])
}
schema::Event::MessagePartUpdated(updated) => {
let schema::EventMessagePartUpdated {
properties,
type_: _,
} = updated;
let schema::EventMessagePartUpdatedProperties { part, delta } = properties;
let mut events = Vec::new();
let (session_id, message_id) = part_session_message(part);
match part {
schema::Part::TextPart(text_part) => {
let schema::TextPart { text, .. } = text_part;
let delta_text = delta.as_ref().unwrap_or(&text).clone();
let stub = stub_message_item(&message_id, ItemRole::Assistant);
events.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: stub }),
)
.synthetic()
.with_raw(raw.clone()),
);
events.push(
EventConversion::new(
UniversalEventType::ItemDelta,
UniversalEventData::ItemDelta(ItemDeltaData {
item_id: String::new(),
native_item_id: Some(message_id.clone()),
delta: delta_text,
}),
)
.with_native_session(session_id.clone())
.with_raw(raw.clone()),
);
}
schema::Part::ReasoningPart(reasoning_part) => {
let reasoning_text = delta
.as_ref()
.cloned()
.unwrap_or_else(|| reasoning_part.text.clone());
let reasoning_id = reasoning_part.id.clone();
let mut started = stub_message_item(&reasoning_id, ItemRole::Assistant);
started.parent_id = Some(message_id.clone());
let completed = UniversalItem {
item_id: String::new(),
native_item_id: Some(reasoning_id),
parent_id: Some(message_id.clone()),
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::Reasoning {
text: reasoning_text,
visibility: ReasoningVisibility::Public,
}],
status: ItemStatus::Completed,
};
events.push(
EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item: started }),
)
.synthetic()
.with_raw(raw.clone()),
);
events.push(
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item: completed }),
)
.with_native_session(session_id.clone())
.with_raw(raw.clone()),
);
}
schema::Part::FilePart(file_part) => {
let file_content = file_part_to_content(file_part);
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(message_id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: vec![file_content],
status: ItemStatus::Completed,
};
events.push(
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
.with_native_session(session_id.clone())
.with_raw(raw.clone()),
);
}
schema::Part::ToolPart(tool_part) => {
let tool_events = tool_part_to_events(&tool_part, &message_id);
for event in tool_events {
events.push(
event
.with_native_session(session_id.clone())
.with_raw(raw.clone()),
);
}
}
schema::Part::SubtaskPart(subtask_part) => {
let detail = serde_json::to_string(subtask_part)
.unwrap_or_else(|_| "subtask".to_string());
let item = status_item("subtask", Some(detail));
events.push(
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
.with_native_session(session_id.clone())
.with_raw(raw.clone()),
);
}
schema::Part::StepStartPart(_)
| schema::Part::StepFinishPart(_)
| schema::Part::SnapshotPart(_)
| schema::Part::PatchPart(_)
| schema::Part::AgentPart(_)
| schema::Part::RetryPart(_)
| schema::Part::CompactionPart(_) => {
let detail = serde_json::to_string(part).unwrap_or_else(|_| "part".to_string());
let item = status_item("part.updated", Some(detail));
events.push(
EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
.with_native_session(session_id.clone())
.with_raw(raw.clone()),
);
}
}
Ok(events)
}
schema::Event::QuestionAsked(asked) => {
let schema::EventQuestionAsked {
properties,
type_: _,
} = asked;
let question = question_from_opencode(properties);
let conversion = EventConversion::new(
UniversalEventType::QuestionRequested,
UniversalEventData::Question(question),
)
.with_native_session(Some(properties.session_id.to_string()))
.with_raw(raw);
Ok(vec![conversion])
}
schema::Event::PermissionAsked(asked) => {
let schema::EventPermissionAsked {
properties,
type_: _,
} = asked;
let permission = permission_from_opencode(properties);
let conversion = EventConversion::new(
UniversalEventType::PermissionRequested,
UniversalEventData::Permission(permission),
)
.with_native_session(Some(properties.session_id.to_string()))
.with_raw(raw);
Ok(vec![conversion])
}
schema::Event::SessionCreated(created) => {
let schema::EventSessionCreated {
properties,
type_: _,
} = created;
let metadata = serde_json::to_value(&properties.info).ok();
let conversion = EventConversion::new(
UniversalEventType::SessionStarted,
UniversalEventData::SessionStarted(SessionStartedData { metadata }),
)
.with_native_session(Some(properties.info.id.to_string()))
.with_raw(raw);
Ok(vec![conversion])
}
schema::Event::SessionStatus(status) => {
let schema::EventSessionStatus {
properties,
type_: _,
} = status;
let status_type = serde_json::to_value(&properties.status)
.ok()
.and_then(|value| {
value
.get("type")
.and_then(Value::as_str)
.map(str::to_string)
});
let detail =
serde_json::to_string(&properties.status).unwrap_or_else(|_| "status".to_string());
let item = status_item("session.status", Some(detail));
let mut events = vec![EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
.with_native_session(Some(properties.session_id.clone()))
.with_raw(raw.clone())];
if matches!(status_type.as_deref(), Some("busy" | "idle")) {
let (event_type, phase) = if status_type.as_deref() == Some("busy") {
(UniversalEventType::TurnStarted, TurnPhase::Started)
} else {
(UniversalEventType::TurnEnded, TurnPhase::Ended)
};
events.push(
EventConversion::new(
event_type,
UniversalEventData::Turn(TurnEventData {
phase,
turn_id: None,
metadata: Some(
serde_json::to_value(&properties.status).unwrap_or(Value::Null),
),
}),
)
.with_native_session(Some(properties.session_id.clone()))
.with_raw(raw),
);
}
Ok(events)
}
schema::Event::SessionIdle(idle) => {
let schema::EventSessionIdle {
properties,
type_: _,
} = idle;
let conversion = EventConversion::new(
UniversalEventType::TurnEnded,
UniversalEventData::Turn(TurnEventData {
phase: TurnPhase::Ended,
turn_id: None,
metadata: None,
}),
)
.with_native_session(Some(properties.session_id.clone()))
.with_raw(raw);
Ok(vec![conversion])
}
schema::Event::SessionError(error) => {
let schema::EventSessionError {
properties,
type_: _,
} = error;
let detail = serde_json::to_string(&properties.error)
.unwrap_or_else(|_| "session error".to_string());
let item = status_item("session.error", Some(detail));
let conversion = EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
)
.with_native_session(properties.session_id.clone())
.with_raw(raw);
Ok(vec![conversion])
}
_ => Err("unsupported opencode event".to_string()),
}
}
fn message_to_item(message: &schema::Message) -> (UniversalItem, bool, Option<String>) {
match message {
schema::Message::UserMessage(user) => {
let schema::UserMessage {
id,
session_id,
role: _,
..
} = user;
(
UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::User),
content: Vec::new(),
status: ItemStatus::Completed,
},
true,
Some(session_id.clone()),
)
}
schema::Message::AssistantMessage(assistant) => {
let schema::AssistantMessage {
id,
session_id,
time,
..
} = assistant;
let completed = time.completed.is_some();
(
UniversalItem {
item_id: String::new(),
native_item_id: Some(id.clone()),
parent_id: None,
kind: ItemKind::Message,
role: Some(ItemRole::Assistant),
content: Vec::new(),
status: if completed {
ItemStatus::Completed
} else {
ItemStatus::InProgress
},
},
completed,
Some(session_id.clone()),
)
}
}
}
fn part_session_message(part: &schema::Part) -> (Option<String>, String) {
match part {
schema::Part::TextPart(text_part) => (
Some(text_part.session_id.clone()),
text_part.message_id.clone(),
),
schema::Part::SubtaskPart(subtask_part) => (
Some(subtask_part.session_id.clone()),
subtask_part.message_id.clone(),
),
schema::Part::ReasoningPart(reasoning_part) => (
Some(reasoning_part.session_id.clone()),
reasoning_part.message_id.clone(),
),
schema::Part::FilePart(file_part) => (
Some(file_part.session_id.clone()),
file_part.message_id.clone(),
),
schema::Part::ToolPart(tool_part) => (
Some(tool_part.session_id.clone()),
tool_part.message_id.clone(),
),
schema::Part::StepStartPart(step_part) => (
Some(step_part.session_id.clone()),
step_part.message_id.clone(),
),
schema::Part::StepFinishPart(step_part) => (
Some(step_part.session_id.clone()),
step_part.message_id.clone(),
),
schema::Part::SnapshotPart(snapshot_part) => (
Some(snapshot_part.session_id.clone()),
snapshot_part.message_id.clone(),
),
schema::Part::PatchPart(patch_part) => (
Some(patch_part.session_id.clone()),
patch_part.message_id.clone(),
),
schema::Part::AgentPart(agent_part) => (
Some(agent_part.session_id.clone()),
agent_part.message_id.clone(),
),
schema::Part::RetryPart(retry_part) => (
Some(retry_part.session_id.clone()),
retry_part.message_id.clone(),
),
schema::Part::CompactionPart(compaction_part) => (
Some(compaction_part.session_id.clone()),
compaction_part.message_id.clone(),
),
}
}
fn stub_message_item(message_id: &str, role: ItemRole) -> UniversalItem {
UniversalItem {
item_id: String::new(),
native_item_id: Some(message_id.to_string()),
parent_id: None,
kind: ItemKind::Message,
role: Some(role),
content: Vec::new(),
status: ItemStatus::InProgress,
}
}
fn tool_part_to_events(tool_part: &schema::ToolPart, message_id: &str) -> Vec<EventConversion> {
let schema::ToolPart {
call_id,
state,
tool,
..
} = tool_part;
let mut events = Vec::new();
match state {
schema::ToolState::Pending(state) => {
let arguments = serde_json::to_string(&Value::Object(state.input.clone()))
.unwrap_or_else(|_| "{}".to_string());
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(call_id.clone()),
parent_id: Some(message_id.to_string()),
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: tool.clone(),
arguments,
call_id: call_id.clone(),
}],
status: ItemStatus::InProgress,
};
events.push(EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item }),
));
}
schema::ToolState::Running(state) => {
let arguments = serde_json::to_string(&Value::Object(state.input.clone()))
.unwrap_or_else(|_| "{}".to_string());
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(call_id.clone()),
parent_id: Some(message_id.to_string()),
kind: ItemKind::ToolCall,
role: Some(ItemRole::Assistant),
content: vec![ContentPart::ToolCall {
name: tool.clone(),
arguments,
call_id: call_id.clone(),
}],
status: ItemStatus::InProgress,
};
events.push(EventConversion::new(
UniversalEventType::ItemStarted,
UniversalEventData::Item(ItemEventData { item }),
));
}
schema::ToolState::Completed(state) => {
let output = state.output.clone();
let mut content = vec![ContentPart::ToolResult {
call_id: call_id.clone(),
output,
}];
for attachment in &state.attachments {
content.push(file_part_to_content(attachment));
}
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(call_id.clone()),
parent_id: Some(message_id.to_string()),
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content,
status: ItemStatus::Completed,
};
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
}
schema::ToolState::Error(state) => {
let output = state.error.clone();
let item = UniversalItem {
item_id: String::new(),
native_item_id: Some(call_id.clone()),
parent_id: Some(message_id.to_string()),
kind: ItemKind::ToolResult,
role: Some(ItemRole::Tool),
content: vec![ContentPart::ToolResult {
call_id: call_id.clone(),
output,
}],
status: ItemStatus::Failed,
};
events.push(EventConversion::new(
UniversalEventType::ItemCompleted,
UniversalEventData::Item(ItemEventData { item }),
));
}
}
events
}
fn file_part_to_content(file_part: &schema::FilePart) -> ContentPart {
let path = file_part.url.clone();
let action = if file_part.mime.starts_with("image/") {
crate::FileAction::Read
} else {
crate::FileAction::Read
};
ContentPart::FileRef {
path,
action,
diff: None,
}
}
fn status_item(label: &str, detail: Option<String>) -> UniversalItem {
UniversalItem {
item_id: String::new(),
native_item_id: None,
parent_id: None,
kind: ItemKind::Status,
role: Some(ItemRole::System),
content: vec![ContentPart::Status {
label: label.to_string(),
detail,
}],
status: ItemStatus::Completed,
}
}
fn question_from_opencode(request: &schema::QuestionRequest) -> QuestionEventData {
let prompt = request
.questions
.first()
.map(|q| q.question.clone())
.unwrap_or_default();
let options = request
.questions
.first()
.map(|q| {
q.options
.iter()
.map(|opt| opt.label.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
QuestionEventData {
question_id: request.id.clone().into(),
prompt,
options,
response: None,
status: QuestionStatus::Requested,
}
}
fn permission_from_opencode(request: &schema::PermissionRequest) -> PermissionEventData {
PermissionEventData {
permission_id: request.id.clone().into(),
action: request.permission.clone(),
status: PermissionStatus::Requested,
metadata: serde_json::to_value(request).ok(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reasoning_part_updates_stay_typed_not_text_delta() {
let event = schema::Event::MessagePartUpdated(schema::EventMessagePartUpdated {
properties: schema::EventMessagePartUpdatedProperties {
delta: Some("Preparing friendly brief response".to_string()),
part: schema::Part::ReasoningPart(schema::ReasoningPart {
id: "part_reason_1".to_string(),
message_id: "msg_1".to_string(),
metadata: serde_json::Map::new(),
session_id: "ses_1".to_string(),
text: "Preparing".to_string(),
time: schema::ReasoningPartTime {
end: None,
start: 0.0,
},
type_: "reasoning".to_string(),
}),
},
type_: "message.part.updated".to_string(),
});
let converted = event_to_universal(&event).expect("conversion succeeds");
assert_eq!(converted.len(), 2);
assert!(converted
.iter()
.all(|entry| entry.event_type != UniversalEventType::ItemDelta));
let completed = converted
.iter()
.find(|entry| entry.event_type == UniversalEventType::ItemCompleted)
.expect("item.completed exists");
let UniversalEventData::Item(ItemEventData { item }) = &completed.data else {
panic!("expected item payload");
};
assert_eq!(item.native_item_id.as_deref(), Some("part_reason_1"));
assert!(matches!(
item.content.first(),
Some(ContentPart::Reasoning { text, .. })
if text == "Preparing friendly brief response"
));
}
}

View file

@ -1,386 +0,0 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::ToSchema;
pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode, pi};
pub mod agents;
pub use agents::{
amp as convert_amp, claude as convert_claude, codex as convert_codex,
opencode as convert_opencode, pi as convert_pi,
};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct UniversalEvent {
pub event_id: String,
pub sequence: u64,
pub time: String,
pub session_id: 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 = "turn.started")]
TurnStarted,
#[serde(rename = "turn.ended")]
TurnEnded,
#[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 {
Turn(TurnEventData),
SessionStarted(SessionStartedData),
SessionEnded(SessionEndedData),
Item(ItemEventData),
ItemDelta(ItemDeltaData),
Error(ErrorData),
Permission(PermissionEventData),
Question(QuestionEventData),
AgentUnparsed(AgentUnparsedData),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct SessionStartedData {
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct SessionEndedData {
pub reason: SessionEndReason,
pub terminated_by: TerminatedBy,
/// Error message when reason is Error
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
/// Process exit code when reason is Error
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
/// Agent stderr output when reason is Error
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr: Option<StderrOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct TurnEventData {
pub phase: TurnPhase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TurnPhase {
Started,
Ended,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct StderrOutput {
/// First N lines of stderr (if truncated) or full stderr (if not truncated)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
/// Last N lines of stderr (only present if truncated)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tail: Option<String>,
/// Whether the output was truncated
pub truncated: bool,
/// Total number of lines in stderr
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_lines: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, 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,
pub code: Option<String>,
pub details: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct AgentUnparsedData {
pub error: String,
pub location: String,
pub raw_hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
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,
Accept,
AcceptForSession,
Reject,
}
#[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, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ItemRole {
User,
Assistant,
System,
Tool,
}
#[derive(Debug, Clone, PartialEq, Eq, 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 ContentPart {
Text {
text: String,
},
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(rename_all = "snake_case")]
pub enum FileAction {
Read,
Write,
Patch,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningVisibility {
Public,
Private,
}
#[derive(Debug, Clone)]
pub struct EventConversion {
pub event_type: UniversalEventType,
pub data: UniversalEventData,
pub native_session_id: Option<String>,
pub source: EventSource,
pub synthetic: bool,
pub raw: Option<Value>,
}
impl EventConversion {
pub fn new(event_type: UniversalEventType, data: UniversalEventData) -> Self {
Self {
event_type,
data,
native_session_id: None,
source: EventSource::Agent,
synthetic: false,
raw: None,
}
}
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
}
}
pub fn turn_started_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion {
EventConversion::new(
UniversalEventType::TurnStarted,
UniversalEventData::Turn(TurnEventData {
phase: TurnPhase::Started,
turn_id,
metadata,
}),
)
}
pub fn turn_ended_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion {
EventConversion::new(
UniversalEventType::TurnEnded,
UniversalEventData::Turn(TurnEventData {
phase: TurnPhase::Ended,
turn_id,
metadata,
}),
)
}
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,
}
}
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,
}
}