mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 04:03:31 +00:00
414 lines
13 KiB
Rust
414 lines
13 KiB
Rust
use sandbox_agent_universal_agent_schema::convert_pi::PiEventConverter;
|
|
use sandbox_agent_universal_agent_schema::pi as pi_schema;
|
|
use sandbox_agent_universal_agent_schema::{
|
|
ContentPart, ItemKind, ItemRole, ItemStatus, UniversalEventData, UniversalEventType,
|
|
};
|
|
use serde_json::json;
|
|
|
|
fn parse_event(value: serde_json::Value) -> pi_schema::RpcEvent {
|
|
serde_json::from_value(value).expect("pi event")
|
|
}
|
|
|
|
#[test]
|
|
fn pi_message_flow_converts() {
|
|
let mut converter = PiEventConverter::default();
|
|
|
|
let start_event = parse_event(json!({
|
|
"type": "message_start",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{ "type": "text", "text": "Hello" }]
|
|
}
|
|
}));
|
|
let start_events = converter
|
|
.event_to_universal(&start_event)
|
|
.expect("start conversions");
|
|
assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted);
|
|
if let UniversalEventData::Item(item) = &start_events[0].data {
|
|
assert_eq!(item.item.kind, ItemKind::Message);
|
|
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
|
assert_eq!(item.item.status, ItemStatus::InProgress);
|
|
} else {
|
|
panic!("expected item event");
|
|
}
|
|
|
|
let update_event = parse_event(json!({
|
|
"type": "message_update",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
|
|
}));
|
|
let update_events = converter
|
|
.event_to_universal(&update_event)
|
|
.expect("update conversions");
|
|
assert_eq!(update_events[0].event_type, UniversalEventType::ItemDelta);
|
|
|
|
let end_event = parse_event(json!({
|
|
"type": "message_end",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{ "type": "text", "text": "Hello world" }]
|
|
}
|
|
}));
|
|
let end_events = converter
|
|
.event_to_universal(&end_event)
|
|
.expect("end conversions");
|
|
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
|
|
if let UniversalEventData::Item(item) = &end_events[0].data {
|
|
assert_eq!(item.item.kind, ItemKind::Message);
|
|
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
|
assert_eq!(item.item.status, ItemStatus::Completed);
|
|
} else {
|
|
panic!("expected item event");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pi_user_message_echo_is_skipped() {
|
|
let mut converter = PiEventConverter::default();
|
|
|
|
// Pi may echo the user message as a message_start with role "user".
|
|
// The daemon already records synthetic user events, so the converter
|
|
// must skip these to avoid a duplicate assistant-looking bubble.
|
|
let start_event = parse_event(json!({
|
|
"type": "message_start",
|
|
"sessionId": "session-1",
|
|
"messageId": "user-msg-1",
|
|
"message": {
|
|
"role": "user",
|
|
"content": [{ "type": "text", "text": "hello!" }]
|
|
}
|
|
}));
|
|
let events = converter
|
|
.event_to_universal(&start_event)
|
|
.expect("user message_start should not error");
|
|
assert!(
|
|
events.is_empty(),
|
|
"user message_start should produce no events, got {}",
|
|
events.len()
|
|
);
|
|
|
|
let end_event = parse_event(json!({
|
|
"type": "message_end",
|
|
"sessionId": "session-1",
|
|
"messageId": "user-msg-1",
|
|
"message": {
|
|
"role": "user",
|
|
"content": [{ "type": "text", "text": "hello!" }]
|
|
}
|
|
}));
|
|
let events = converter
|
|
.event_to_universal(&end_event)
|
|
.expect("user message_end should not error");
|
|
assert!(
|
|
events.is_empty(),
|
|
"user message_end should produce no events, got {}",
|
|
events.len()
|
|
);
|
|
|
|
// A subsequent assistant message should still work normally.
|
|
let assistant_start = parse_event(json!({
|
|
"type": "message_start",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{ "type": "text", "text": "Hello! How can I help?" }]
|
|
}
|
|
}));
|
|
let events = converter
|
|
.event_to_universal(&assistant_start)
|
|
.expect("assistant message_start");
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0].event_type, UniversalEventType::ItemStarted);
|
|
if let UniversalEventData::Item(item) = &events[0].data {
|
|
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
|
} else {
|
|
panic!("expected item event");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pi_tool_execution_converts_with_partial_deltas() {
|
|
let mut converter = PiEventConverter::default();
|
|
|
|
let start_event = parse_event(json!({
|
|
"type": "tool_execution_start",
|
|
"sessionId": "session-1",
|
|
"toolCallId": "call-1",
|
|
"toolName": "bash",
|
|
"args": { "command": "ls" }
|
|
}));
|
|
let start_events = converter
|
|
.event_to_universal(&start_event)
|
|
.expect("tool start");
|
|
assert_eq!(start_events[0].event_type, UniversalEventType::ItemStarted);
|
|
if let UniversalEventData::Item(item) = &start_events[0].data {
|
|
assert_eq!(item.item.kind, ItemKind::ToolCall);
|
|
assert_eq!(item.item.role, Some(ItemRole::Assistant));
|
|
match &item.item.content[0] {
|
|
ContentPart::ToolCall { name, .. } => assert_eq!(name, "bash"),
|
|
_ => panic!("expected tool call content"),
|
|
}
|
|
}
|
|
|
|
let update_event = parse_event(json!({
|
|
"type": "tool_execution_update",
|
|
"sessionId": "session-1",
|
|
"toolCallId": "call-1",
|
|
"partialResult": "foo"
|
|
}));
|
|
let update_events = converter
|
|
.event_to_universal(&update_event)
|
|
.expect("tool update");
|
|
assert!(update_events
|
|
.iter()
|
|
.any(|event| event.event_type == UniversalEventType::ItemDelta));
|
|
|
|
let update_event2 = parse_event(json!({
|
|
"type": "tool_execution_update",
|
|
"sessionId": "session-1",
|
|
"toolCallId": "call-1",
|
|
"partialResult": "foobar"
|
|
}));
|
|
let update_events2 = converter
|
|
.event_to_universal(&update_event2)
|
|
.expect("tool update 2");
|
|
let delta = update_events2
|
|
.iter()
|
|
.find_map(|event| match &event.data {
|
|
UniversalEventData::ItemDelta(data) => Some(data.delta.clone()),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_default();
|
|
assert_eq!(delta, "bar");
|
|
|
|
let end_event = parse_event(json!({
|
|
"type": "tool_execution_end",
|
|
"sessionId": "session-1",
|
|
"toolCallId": "call-1",
|
|
"result": { "type": "text", "content": "done" },
|
|
"isError": false
|
|
}));
|
|
let end_events = converter.event_to_universal(&end_event).expect("tool end");
|
|
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
|
|
if let UniversalEventData::Item(item) = &end_events[0].data {
|
|
assert_eq!(item.item.kind, ItemKind::ToolResult);
|
|
assert_eq!(item.item.role, Some(ItemRole::Tool));
|
|
match &item.item.content[0] {
|
|
ContentPart::ToolResult { output, .. } => assert_eq!(output, "done"),
|
|
_ => panic!("expected tool result content"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pi_unknown_event_returns_error() {
|
|
let mut converter = PiEventConverter::default();
|
|
let event = parse_event(json!({
|
|
"type": "unknown_event",
|
|
"sessionId": "session-1"
|
|
}));
|
|
assert!(converter.event_to_universal(&event).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn pi_turn_and_agent_end_emit_terminal_status_labels() {
|
|
let mut converter = PiEventConverter::default();
|
|
|
|
let turn_end = parse_event(json!({
|
|
"type": "turn_end",
|
|
"sessionId": "session-1"
|
|
}));
|
|
let turn_events = converter
|
|
.event_to_universal(&turn_end)
|
|
.expect("turn_end conversions");
|
|
assert_eq!(turn_events[0].event_type, UniversalEventType::ItemCompleted);
|
|
if let UniversalEventData::Item(item) = &turn_events[0].data {
|
|
assert_eq!(item.item.kind, ItemKind::Status);
|
|
assert!(
|
|
matches!(
|
|
item.item.content.first(),
|
|
Some(ContentPart::Status { label, .. }) if label == "turn.completed"
|
|
),
|
|
"turn_end should map to turn.completed status"
|
|
);
|
|
} else {
|
|
panic!("expected item event");
|
|
}
|
|
|
|
let agent_end = parse_event(json!({
|
|
"type": "agent_end",
|
|
"sessionId": "session-1"
|
|
}));
|
|
let agent_events = converter
|
|
.event_to_universal(&agent_end)
|
|
.expect("agent_end conversions");
|
|
assert_eq!(
|
|
agent_events[0].event_type,
|
|
UniversalEventType::ItemCompleted
|
|
);
|
|
if let UniversalEventData::Item(item) = &agent_events[0].data {
|
|
assert_eq!(item.item.kind, ItemKind::Status);
|
|
assert!(
|
|
matches!(
|
|
item.item.content.first(),
|
|
Some(ContentPart::Status { label, .. }) if label == "session.idle"
|
|
),
|
|
"agent_end should map to session.idle status"
|
|
);
|
|
} else {
|
|
panic!("expected item event");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pi_message_done_completes_without_message_end() {
|
|
let mut converter = PiEventConverter::default();
|
|
|
|
let start_event = parse_event(json!({
|
|
"type": "message_start",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{ "type": "text", "text": "Hello" }]
|
|
}
|
|
}));
|
|
let _start_events = converter
|
|
.event_to_universal(&start_event)
|
|
.expect("start conversions");
|
|
|
|
let update_event = parse_event(json!({
|
|
"type": "message_update",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
|
|
}));
|
|
let _update_events = converter
|
|
.event_to_universal(&update_event)
|
|
.expect("update conversions");
|
|
|
|
let done_event = parse_event(json!({
|
|
"type": "message_update",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"assistantMessageEvent": { "type": "done" }
|
|
}));
|
|
let done_events = converter
|
|
.event_to_universal(&done_event)
|
|
.expect("done conversions");
|
|
assert_eq!(done_events[0].event_type, UniversalEventType::ItemCompleted);
|
|
if let UniversalEventData::Item(item) = &done_events[0].data {
|
|
assert_eq!(item.item.status, ItemStatus::Completed);
|
|
assert!(
|
|
matches!(item.item.content.get(0), Some(ContentPart::Text { text }) if text == "Hello world")
|
|
);
|
|
} else {
|
|
panic!("expected item event");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pi_message_done_then_message_end_does_not_double_complete() {
|
|
let mut converter = PiEventConverter::default();
|
|
|
|
let start_event = parse_event(json!({
|
|
"type": "message_start",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{ "type": "text", "text": "Hello" }]
|
|
}
|
|
}));
|
|
let _ = converter
|
|
.event_to_universal(&start_event)
|
|
.expect("start conversions");
|
|
|
|
let update_event = parse_event(json!({
|
|
"type": "message_update",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"assistantMessageEvent": { "type": "text_delta", "delta": " world" }
|
|
}));
|
|
let _ = converter
|
|
.event_to_universal(&update_event)
|
|
.expect("update conversions");
|
|
|
|
let done_event = parse_event(json!({
|
|
"type": "message_update",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"assistantMessageEvent": { "type": "done" }
|
|
}));
|
|
let done_events = converter
|
|
.event_to_universal(&done_event)
|
|
.expect("done conversions");
|
|
assert_eq!(done_events.len(), 1);
|
|
assert_eq!(done_events[0].event_type, UniversalEventType::ItemCompleted);
|
|
|
|
let end_event = parse_event(json!({
|
|
"type": "message_end",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-1",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{ "type": "text", "text": "Hello world" }]
|
|
}
|
|
}));
|
|
let end_events = converter
|
|
.event_to_universal(&end_event)
|
|
.expect("end conversions");
|
|
assert!(
|
|
end_events.is_empty(),
|
|
"message_end after done should not emit a second completion"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pi_message_end_error_surfaces_failed_status_and_error_text() {
|
|
let mut converter = PiEventConverter::default();
|
|
|
|
let start_event = parse_event(json!({
|
|
"type": "message_start",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-err",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": []
|
|
}
|
|
}));
|
|
let _ = converter
|
|
.event_to_universal(&start_event)
|
|
.expect("start conversions");
|
|
|
|
let end_raw = json!({
|
|
"type": "message_end",
|
|
"sessionId": "session-1",
|
|
"messageId": "msg-err",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [],
|
|
"stopReason": "error",
|
|
"errorMessage": "Connection error."
|
|
}
|
|
});
|
|
let end_events = converter
|
|
.event_value_to_universal(&end_raw)
|
|
.expect("end conversions");
|
|
|
|
assert_eq!(end_events[0].event_type, UniversalEventType::ItemCompleted);
|
|
if let UniversalEventData::Item(item) = &end_events[0].data {
|
|
assert_eq!(item.item.status, ItemStatus::Failed);
|
|
assert!(
|
|
matches!(item.item.content.first(), Some(ContentPart::Text { text }) if text == "Connection error.")
|
|
);
|
|
} else {
|
|
panic!("expected item event");
|
|
}
|
|
}
|