use super::*;
use crate::agent::agent_loop::message::{
AssistantMessage, ContentBlock, StopReason, ToolResultMessage, UserMessage,
};
use crate::agent::agent_loop::result::LoopToolResult;
fn assistant_with_text(s: &str) -> AssistantMessage {
AssistantMessage::new(
vec![ContentBlock::Text {
text: s.to_string(),
}],
StopReason::Stop,
)
}
fn assistant_with_thinking(s: &str) -> AssistantMessage {
AssistantMessage::new(
vec![ContentBlock::Thinking {
text: s.to_string(),
}],
StopReason::Stop,
)
}
#[test]
fn turn_start_end_index_round_trips() {
let mut bridge = EventBridge::new();
let s0 = bridge.translate(LoopEvent::TurnStart);
let e0 = bridge.translate(LoopEvent::TurnEnd {
message: assistant_with_text("hi"),
tool_results: Vec::new(),
});
let s1 = bridge.translate(LoopEvent::TurnStart);
let e1 = bridge.translate(LoopEvent::TurnEnd {
message: assistant_with_text("again"),
tool_results: Vec::new(),
});
assert!(matches!(
s0.as_slice(),
[AgentEvent::TurnStart { index: 0 }]
));
assert!(matches!(e0.as_slice(), [AgentEvent::TurnEnd { index: 0 }]));
assert!(matches!(
s1.as_slice(),
[AgentEvent::TurnStart { index: 1 }]
));
assert!(matches!(e1.as_slice(), [AgentEvent::TurnEnd { index: 1 }]));
}
#[test]
fn agent_start_no_op_agent_end_emits_done() {
let mut bridge = EventBridge::new();
assert!(bridge.translate(LoopEvent::AgentStart).is_empty());
let messages = vec![
LoopMessage::User(UserMessage {
content: "hi".to_string(),
}),
LoopMessage::Assistant(assistant_with_text("final answer")),
];
let out = bridge.translate(LoopEvent::AgentEnd { messages });
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Done {
response,
tokens,
cost,
} => {
assert_eq!(response.as_str(), "final answer");
assert_eq!(*tokens, 0);
assert_eq!(*cost, 0.0);
}
_ => panic!("expected Done"),
}
}
#[test]
fn agent_end_context_length_error_emits_context_overflow() {
let mut bridge = EventBridge::new();
let mut a = assistant_with_text("");
a.stop_reason = StopReason::Error;
a.error_message = Some("prompt is too long: maximum context length exceeded".to_string());
let messages = vec![
LoopMessage::User(UserMessage {
content: "summarize this huge doc".to_string(),
}),
LoopMessage::Assistant(a),
];
let out = bridge.translate(LoopEvent::AgentEnd { messages });
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::ContextOverflow { prompt, error } => {
assert_eq!(prompt.as_str(), "summarize this huge doc");
assert!(
error.contains("context length") || error.contains("too long"),
"error text should mention context length"
);
}
other => panic!("expected ContextOverflow, got {other:?}"),
}
}
#[test]
fn agent_end_non_context_error_emits_error() {
let mut bridge = EventBridge::new();
let mut a = assistant_with_text("");
a.stop_reason = StopReason::Error;
a.error_message = Some("401 unauthorized: invalid api key".to_string());
let messages = vec![
LoopMessage::User(UserMessage {
content: "hi".to_string(),
}),
LoopMessage::Assistant(a),
];
let out = bridge.translate(LoopEvent::AgentEnd { messages });
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Error(msg) => {
assert!(msg.contains("unauthorized"));
}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn agent_end_cancellation_emits_interjected_not_error() {
let mut bridge = EventBridge::new();
let mut a = assistant_with_text("partial response before interject");
a.stop_reason = StopReason::Error;
a.error_message = Some("stream aborted by cancellation signal".to_string());
let messages = vec![
LoopMessage::User(UserMessage {
content: "do a long thing".to_string(),
}),
LoopMessage::Assistant(a),
];
let out = bridge.translate(LoopEvent::AgentEnd { messages });
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Interjected {
partial_response,
tokens,
} => {
assert_eq!(
partial_response.as_str(),
"partial response before interject"
);
assert_eq!(*tokens, 0);
}
other => panic!("expected Interjected, got {other:?}"),
}
}
#[test]
fn agent_end_error_without_message_still_emits_error() {
let mut bridge = EventBridge::new();
let mut a = assistant_with_text("");
a.stop_reason = StopReason::Error;
a.error_message = None;
let messages = vec![LoopMessage::Assistant(a)];
let out = bridge.translate(LoopEvent::AgentEnd { messages });
assert_eq!(out.len(), 1);
assert!(matches!(out[0], AgentEvent::Error(_)));
}
#[test]
fn agent_end_aborted_emits_done() {
let mut bridge = EventBridge::new();
let mut a = assistant_with_text("partial work");
a.stop_reason = StopReason::Aborted;
let messages = vec![LoopMessage::Assistant(a)];
let out = bridge.translate(LoopEvent::AgentEnd { messages });
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Done { response, .. } => {
assert_eq!(response.as_str(), "partial work");
}
other => panic!("expected Done, got {other:?}"),
}
}
#[test]
fn agent_end_no_assistant_done_empty_response() {
let mut bridge = EventBridge::new();
let messages = vec![LoopMessage::User(UserMessage {
content: "hi".to_string(),
})];
let out = bridge.translate(LoopEvent::AgentEnd { messages });
match &out[0] {
AgentEvent::Done { response, .. } => {
assert_eq!(response.as_str(), "");
}
_ => panic!("expected Done"),
}
}
#[test]
fn text_delta_emits_token_chunks() {
let mut bridge = EventBridge::new();
let out = bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_text("Hello"),
phase: DeltaPhase::TextStart,
});
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Token(s) => assert_eq!(s.as_str(), "Hello"),
_ => panic!("expected Token"),
}
let out = bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_text("Hello world"),
phase: DeltaPhase::TextDelta,
});
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Token(s) => assert_eq!(s.as_str(), " world"),
_ => panic!("expected Token"),
}
let out = bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_text("Hello world"),
phase: DeltaPhase::TextDelta,
});
assert!(out.is_empty());
}
#[test]
fn reasoning_delta_emits_reasoning_chunks() {
let mut bridge = EventBridge::new();
let out = bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_thinking("Let me think"),
phase: DeltaPhase::ThinkingStart,
});
match &out[0] {
AgentEvent::Reasoning(s) => assert_eq!(s.as_str(), "Let me think"),
_ => panic!("expected Reasoning"),
}
let out = bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_thinking("Let me think about this"),
phase: DeltaPhase::ThinkingDelta,
});
match &out[0] {
AgentEvent::Reasoning(s) => assert_eq!(s.as_str(), " about this"),
_ => panic!("expected Reasoning"),
}
}
#[test]
fn text_and_reasoning_tracked_independently() {
let mut bridge = EventBridge::new();
let _ = bridge.translate(LoopEvent::MessageUpdate {
message: AssistantMessage::new(
vec![ContentBlock::Thinking {
text: "thinking".to_string(),
}],
StopReason::Stop,
),
phase: DeltaPhase::ThinkingStart,
});
let out = bridge.translate(LoopEvent::MessageUpdate {
message: AssistantMessage::new(
vec![
ContentBlock::Thinking {
text: "thinking".to_string(),
},
ContentBlock::Text {
text: "answer".to_string(),
},
],
StopReason::Stop,
),
phase: DeltaPhase::TextStart,
});
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Token(s) => assert_eq!(s.as_str(), "answer"),
_ => panic!("expected Token"),
}
}
#[test]
fn tool_execution_start_emits_call_and_started() {
let mut bridge = EventBridge::new();
let out = bridge.translate(LoopEvent::ToolExecutionStart {
tool_call_id: "call-1".to_string(),
tool_name: "read".to_string(),
args: serde_json::json!({"path": "/tmp/x"}),
});
assert_eq!(out.len(), 2);
match &out[0] {
AgentEvent::ToolCall { id, name, args } => {
assert_eq!(id.as_str(), "call-1");
assert_eq!(name.as_str(), "read");
assert_eq!(args["path"], "/tmp/x");
}
_ => panic!("expected ToolCall"),
}
match &out[1] {
AgentEvent::ToolStarted { id } => {
assert_eq!(id.as_str(), "call-1");
}
_ => panic!("expected ToolStarted"),
}
}
#[test]
fn tool_execution_end_classifies_file_tools_as_file() {
let mut bridge = EventBridge::new();
let _ = bridge.translate(LoopEvent::ToolExecutionStart {
tool_call_id: "call-1".to_string(),
tool_name: "read".to_string(),
args: serde_json::json!({}),
});
let out = bridge.translate(LoopEvent::ToolExecutionEnd {
tool_call_id: "call-1".to_string(),
tool_name: "read".to_string(),
result: LoopToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "file contents here"
})],
details: serde_json::Value::Null,
terminate: None,
},
is_error: false,
});
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::ToolResult { id, output, kind } => {
assert_eq!(id.as_str(), "call-1");
assert_eq!(output.as_str(), "file contents here");
assert!(matches!(kind, ToolContent::File));
}
_ => panic!("expected ToolResult"),
}
}
#[test]
fn tool_execution_end_classifies_other_tools_as_text() {
let mut bridge = EventBridge::new();
let _ = bridge.translate(LoopEvent::ToolExecutionStart {
tool_call_id: "call-2".to_string(),
tool_name: "bash".to_string(),
args: serde_json::json!({}),
});
let out = bridge.translate(LoopEvent::ToolExecutionEnd {
tool_call_id: "call-2".to_string(),
tool_name: "bash".to_string(),
result: LoopToolResult {
content: vec![serde_json::json!({"type": "text", "text": "stdout"})],
details: serde_json::Value::Null,
terminate: None,
},
is_error: false,
});
match &out[0] {
AgentEvent::ToolResult { kind, .. } => {
assert!(matches!(kind, ToolContent::Text));
}
_ => panic!("expected ToolResult"),
}
}
#[test]
fn message_start_custom_emits_custom_message_event() {
let mut bridge = EventBridge::new();
let payload = serde_json::json!({"type": "status", "content": "hello"});
let events = bridge.translate(LoopEvent::MessageStart {
message: LoopMessage::Custom(payload.clone()),
});
assert_eq!(events.len(), 1);
match &events[0] {
AgentEvent::CustomMessage { payload: out } => assert_eq!(out, &payload),
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn message_start_end_behavior() {
let mut bridge = EventBridge::new();
let user_msg = LoopMessage::User(UserMessage {
content: "hi".to_string(),
});
let events = bridge.translate(LoopEvent::MessageStart {
message: user_msg.clone(),
});
assert_eq!(events.len(), 1);
match &events[0] {
AgentEvent::UserMessage { content } => assert_eq!(content.as_str(), "hi"),
other => panic!("expected UserMessage, got {other:?}"),
}
assert!(
bridge
.translate(LoopEvent::MessageEnd { message: user_msg })
.is_empty()
);
let tool_msg = LoopMessage::ToolResult(ToolResultMessage {
tool_call_id: "c1".to_string(),
tool_name: "echo".to_string(),
content: Vec::new(),
details: serde_json::Value::Null,
is_error: false,
});
assert!(
bridge
.translate(LoopEvent::MessageStart {
message: tool_msg.clone()
})
.is_empty()
);
assert!(
bridge
.translate(LoopEvent::MessageEnd { message: tool_msg })
.is_empty()
);
}
#[test]
fn message_update_end_phases_are_no_ops() {
let mut bridge = EventBridge::new();
for phase in [
DeltaPhase::TextEnd,
DeltaPhase::ThinkingEnd,
DeltaPhase::ToolCallStart,
DeltaPhase::ToolCallDelta,
DeltaPhase::ToolCallEnd,
] {
let out = bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_text("any"),
phase,
});
assert!(out.is_empty(), "phase {phase:?} should be no-op");
}
}
#[test]
fn parallel_tool_execution_ends_preserve_distinct_content() {
let mut bridge = EventBridge::new();
let n = 7;
for i in 0..n {
let evts = bridge.translate(LoopEvent::ToolExecutionStart {
tool_call_id: format!("call-{i}"),
tool_name: "read".to_string(),
args: serde_json::json!({"path": format!("/f{i}")}),
});
assert_eq!(evts.len(), 2, "start {i} should emit 2 events");
}
for i in (0..n).rev() {
let payload = format!("file-{i}-contents");
let evts = bridge.translate(LoopEvent::ToolExecutionEnd {
tool_call_id: format!("call-{i}"),
tool_name: "read".to_string(),
result: LoopToolResult {
content: vec![serde_json::json!({"type": "text", "text": payload})],
details: serde_json::Value::Null,
terminate: None,
},
is_error: false,
});
assert_eq!(evts.len(), 1, "end {i} should emit 1 event");
match &evts[0] {
AgentEvent::ToolResult { id, output, kind } => {
assert_eq!(id.as_str(), format!("call-{i}"));
assert!(
!output.is_empty(),
"ToolResult for call-{i} had empty output"
);
assert_eq!(output.as_str(), format!("file-{i}-contents"));
assert!(matches!(kind, ToolContent::File));
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
}
#[test]
fn empty_loop_tool_result_content_produces_empty_output() {
let mut bridge = EventBridge::new();
let _ = bridge.translate(LoopEvent::ToolExecutionStart {
tool_call_id: "c1".to_string(),
tool_name: "read".to_string(),
args: serde_json::json!({}),
});
let out = bridge.translate(LoopEvent::ToolExecutionEnd {
tool_call_id: "c1".to_string(),
tool_name: "read".to_string(),
result: LoopToolResult {
content: vec![],
details: serde_json::Value::Null,
terminate: None,
},
is_error: false,
});
match &out[0] {
AgentEvent::ToolResult { output, .. } => {
assert!(
output.is_empty(),
"empty content Vec must produce empty output (no synthesis)"
);
}
_ => panic!("expected ToolResult"),
}
}
#[test]
fn flatten_content_joins_text_blocks() {
let blocks = vec![
serde_json::json!({"type": "text", "text": "line 1"}),
serde_json::json!({"type": "text", "text": "line 2"}),
];
assert_eq!(flatten_content(&blocks), "line 1\nline 2");
}
#[test]
fn flatten_content_stringifies_unknown_blocks() {
let blocks = vec![
serde_json::json!({"type": "text", "text": "hello"}),
serde_json::json!({"type": "image", "url": "https://example/x.png"}),
];
let out = flatten_content(&blocks);
assert!(out.contains("hello"));
assert!(out.contains("image"));
}
#[test]
fn full_run_event_sequence_translates_correctly() {
let mut bridge = EventBridge::new();
let mut all = Vec::new();
all.extend(bridge.translate(LoopEvent::AgentStart));
all.extend(bridge.translate(LoopEvent::TurnStart));
all.extend(bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_text("Sure, "),
phase: DeltaPhase::TextStart,
}));
all.extend(bridge.translate(LoopEvent::MessageUpdate {
message: assistant_with_text("Sure, I'll help."),
phase: DeltaPhase::TextDelta,
}));
all.extend(bridge.translate(LoopEvent::ToolExecutionStart {
tool_call_id: "c1".to_string(),
tool_name: "read".to_string(),
args: serde_json::json!({"path": "/x"}),
}));
all.extend(bridge.translate(LoopEvent::ToolExecutionEnd {
tool_call_id: "c1".to_string(),
tool_name: "read".to_string(),
result: LoopToolResult {
content: vec![serde_json::json!({"type": "text", "text": "data"})],
details: serde_json::Value::Null,
terminate: None,
},
is_error: false,
}));
all.extend(bridge.translate(LoopEvent::TurnEnd {
message: assistant_with_text("Sure, I'll help."),
tool_results: Vec::new(),
}));
all.extend(bridge.translate(LoopEvent::AgentEnd {
messages: vec![LoopMessage::Assistant(assistant_with_text(
"final response",
))],
}));
let kinds: Vec<_> = all
.iter()
.map(|e| match e {
AgentEvent::Token(_) => "Token",
AgentEvent::Reasoning(_) => "Reasoning",
AgentEvent::ToolCall { .. } => "ToolCall",
AgentEvent::ToolStarted { .. } => "ToolStarted",
AgentEvent::ToolResult { .. } => "ToolResult",
AgentEvent::TurnStart { .. } => "TurnStart",
AgentEvent::TurnEnd { .. } => "TurnEnd",
AgentEvent::Usage { .. } => "Usage",
AgentEvent::Done { .. } => "Done",
AgentEvent::Error(_) => "Error",
AgentEvent::ContextOverflow { .. } => "ContextOverflow",
AgentEvent::CompactionStarted { .. } => "CompactionStarted",
AgentEvent::ContextCompacted { .. } => "ContextCompacted",
AgentEvent::CheckpointRefresh { .. } => "CheckpointRefresh",
AgentEvent::Interjected { .. } => "Interjected",
AgentEvent::CustomMessage { .. } => "CustomMessage",
AgentEvent::UserMessage { .. } => "UserMessage",
AgentEvent::RetryNotice { .. } => "RetryNotice",
AgentEvent::SystemNotice { .. } => "SystemNotice",
AgentEvent::RepairStats { .. } => "RepairStats",
AgentEvent::EscalationActivated { .. } => "EscalationActivated",
})
.collect();
assert_eq!(
kinds,
vec![
"TurnStart",
"Token",
"Token",
"ToolCall",
"ToolStarted",
"ToolResult",
"TurnEnd",
"Done",
]
);
}
#[test]
fn usage_event_translates_with_cache_counts() {
use crate::agent::agent_loop::message::TokenUsage;
let mut bridge = EventBridge::new();
let out = bridge.translate(LoopEvent::Usage {
usage: TokenUsage {
input_tokens: 1000,
output_tokens: 50,
cached_input_tokens: 800,
cache_creation_input_tokens: 0,
},
});
assert_eq!(out.len(), 1);
match &out[0] {
AgentEvent::Usage {
input_tokens,
cached_input_tokens,
cache_creation_input_tokens,
} => {
assert_eq!(*input_tokens, 1000);
assert_eq!(*cached_input_tokens, 800);
assert_eq!(*cache_creation_input_tokens, 0);
}
other => panic!("expected AgentEvent::Usage, got {other:?}"),
}
}