use agent_client_protocol::schema::v1::{
ContentBlock, SessionId, SessionUpdate, StopReason, ToolCallStatus, ToolKind,
};
use pretty_assertions::assert_eq;
use rho_sdk::{
model::{ContentBlock as SdkContentBlock, Message, ToolCall},
tool::{OperationKind, ToolMetadata, ToolOutput, ToolProgress},
ProviderStreamResetReason, Revision, RunEvent, RunId, ToolCallId, ToolCompletion,
};
use serde_json::json;
use super::{map_sdk_stop_reason, EventMapper, PROVIDER_STREAM_RESET_NOTICE};
fn session_id() -> SessionId {
SessionId::new("session-1")
}
fn only_update(mapper: &mut EventMapper, event: RunEvent) -> SessionUpdate {
mapper
.map_event(&session_id(), &event)
.expect("one notification")
.update
}
fn text_of(block: &ContentBlock) -> &str {
match block {
ContentBlock::Text(text) => text.text.as_str(),
other => panic!("expected text content, got {other:?}"),
}
}
#[test]
fn assistant_delta_maps_to_agent_message_chunk() {
let update = only_update(
&mut EventMapper::new(),
RunEvent::AssistantTextDelta { text: "hi".into() },
);
match update {
SessionUpdate::AgentMessageChunk(chunk) => assert_eq!(text_of(&chunk.content), "hi"),
other => panic!("expected AgentMessageChunk, got {other:?}"),
}
}
#[test]
fn reasoning_deltas_map_to_thought_chunks() {
for event in [
RunEvent::ReasoningDelta {
text: "think".into(),
},
RunEvent::ReasoningSummaryDelta {
text: "think".into(),
},
] {
let update = only_update(&mut EventMapper::new(), event);
match update {
SessionUpdate::AgentThoughtChunk(chunk) => assert_eq!(text_of(&chunk.content), "think"),
other => panic!("expected AgentThoughtChunk, got {other:?}"),
}
}
}
#[test]
fn proposed_started_finished_map_kind_and_status() {
let mut mapper = EventMapper::new();
let call_id = ToolCallId::from_string("call-1").unwrap();
let proposed = mapper.map_event(
&session_id(),
&RunEvent::ToolProposed {
call: ToolCall {
id: "call-1".into(),
name: "write".into(),
arguments: json!({"path": "a.rs"}),
},
},
);
assert_eq!(proposed, None);
let started = only_update(
&mut mapper,
RunEvent::ToolStarted {
call_id: call_id.clone(),
name: "write".into(),
metadata: ToolMetadata::new()
.operation(OperationKind::Write)
.affected_path("a.rs")
.command_summary("patch a.rs"),
},
);
match started {
SessionUpdate::ToolCall(tool) => {
assert_eq!(tool.tool_call_id.0.as_ref(), "call-1");
assert_eq!(tool.kind, ToolKind::Edit);
assert_eq!(tool.status, ToolCallStatus::InProgress);
assert_eq!(tool.raw_input, Some(json!({"path": "a.rs"})));
assert_eq!(
tool.locations
.iter()
.map(|location| location.path.clone())
.collect::<Vec<_>>(),
vec![std::path::PathBuf::from("a.rs")]
);
}
other => panic!("expected ToolCall, got {other:?}"),
}
let finished = only_update(
&mut mapper,
RunEvent::ToolFinished {
call_id,
result: ToolCompletion::Success(
ToolOutput::text("ok").metadata(ToolMetadata::new().diff("diff --git a/a.rs")),
),
},
);
match finished {
SessionUpdate::ToolCallUpdate(update) => {
assert_eq!(update.fields.status, Some(ToolCallStatus::Completed));
assert_eq!(update.fields.content.as_ref().map(Vec::len), Some(2));
}
other => panic!("expected ToolCallUpdate, got {other:?}"),
}
}
#[test]
fn provider_stream_reset_emits_discard_thought() {
let update = only_update(
&mut EventMapper::new(),
RunEvent::ProviderStreamReset {
reason: ProviderStreamResetReason::InvalidResponse,
detail: "retry".into(),
},
);
match update {
SessionUpdate::AgentThoughtChunk(chunk) => {
assert_eq!(text_of(&chunk.content), PROVIDER_STREAM_RESET_NOTICE);
}
other => panic!("expected AgentThoughtChunk, got {other:?}"),
}
}
#[test]
fn ignored_events_emit_nothing() {
let mut mapper = EventMapper::new();
let ignored = [
RunEvent::Started {
run_id: RunId::from_string("run-1").unwrap(),
revision: Revision::INITIAL,
},
RunEvent::StepStarted { step: 1 },
RunEvent::ToolCallUpdated {
index: 0,
id: Some("call-1".into()),
name: Some("write".into()),
arguments_delta: "{".into(),
},
RunEvent::UsageUpdated {
usage: rho_sdk::model::ModelUsage {
input_tokens: None,
output_tokens: None,
cache_read_tokens: None,
cache_write_tokens: None,
total_tokens: None,
context_window: None,
cost_usd_micros: None,
},
},
RunEvent::Cancelled {
revision: Revision::INITIAL,
},
RunEvent::Failed {
message: "boom".into(),
retryability: rho_sdk::Retryability::Permanent,
},
RunEvent::SteeringApplied { ids: Vec::new() },
RunEvent::ProviderRequestRetry,
RunEvent::WebSearch {
detail: "search".into(),
},
RunEvent::ContextEstimated { tokens: 12 },
RunEvent::HostedToolActivity {
name: "x_search".into(),
detail: "hit".into(),
},
];
for event in ignored {
assert_eq!(mapper.map_event(&session_id(), &event), None);
}
}
#[test]
fn map_stop_maps_sdk_stop_reasons() {
let cases = [
(rho_sdk::StopReason::EndTurn, StopReason::EndTurn),
(rho_sdk::StopReason::MaxSteps, StopReason::MaxTurnRequests),
];
for (sdk, expected) in cases {
assert_eq!(map_sdk_stop_reason(sdk), expected);
}
}
#[test]
fn replay_history_emits_user_and_assistant_text() {
let notifications = EventMapper::replay_history(
&session_id(),
&[
Message::user_text("hello"),
Message::assistant_text("world"),
Message::Assistant(vec![SdkContentBlock::ToolCall(ToolCall {
id: "call-1".into(),
name: "read".into(),
arguments: json!({}),
})]),
],
);
assert_eq!(notifications.len(), 2);
match ¬ifications[0].update {
SessionUpdate::UserMessageChunk(chunk) => assert_eq!(text_of(&chunk.content), "hello"),
other => panic!("expected UserMessageChunk, got {other:?}"),
}
match ¬ifications[1].update {
SessionUpdate::AgentMessageChunk(chunk) => assert_eq!(text_of(&chunk.content), "world"),
other => panic!("expected AgentMessageChunk, got {other:?}"),
}
}
#[test]
fn finished_unified_diff_is_patch_text() {
let finished = only_update(
&mut EventMapper::new(),
RunEvent::ToolFinished {
call_id: ToolCallId::from_string("call-1").unwrap(),
result: ToolCompletion::Success(
ToolOutput::text("ok").metadata(
ToolMetadata::new()
.affected_path("a.rs")
.diff("diff --git a/a.rs"),
),
),
},
);
match finished {
SessionUpdate::ToolCallUpdate(update) => {
let content = update.fields.content.expect("finished content");
assert_eq!(content.len(), 2);
match &content[1] {
agent_client_protocol::schema::v1::ToolCallContent::Content(block) => {
assert_eq!(text_of(&block.content), "diff --git a/a.rs");
}
other => panic!("expected patch text, got {other:?}"),
}
}
other => panic!("expected ToolCallUpdate, got {other:?}"),
}
}
#[test]
fn tool_updated_is_in_progress() {
let update = only_update(
&mut EventMapper::new(),
RunEvent::ToolUpdated {
call_id: ToolCallId::from_string("call-1").unwrap(),
progress: ToolProgress::message("working"),
},
);
match update {
SessionUpdate::ToolCallUpdate(update) => {
assert_eq!(update.fields.status, Some(ToolCallStatus::InProgress));
let content = update.fields.content.expect("progress content");
assert_eq!(content.len(), 1);
match &content[0] {
agent_client_protocol::schema::v1::ToolCallContent::Content(block) => {
assert_eq!(text_of(&block.content), "working");
}
other => panic!("expected progress text, got {other:?}"),
}
}
other => panic!("expected ToolCallUpdate, got {other:?}"),
}
}