mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 00:02:19 +00:00
acp spec (#155)
This commit is contained in:
parent
70287ec471
commit
e72eb9f611
264 changed files with 18559 additions and 51021 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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()))
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
pub mod amp;
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod opencode;
|
||||
pub mod pi;
|
||||
|
|
@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue