use std::collections::{BTreeMap, HashMap};
use agent_client_protocol::schema::v1::{
ContentBlock, ContentChunk, Diff, SessionId, SessionNotification, SessionUpdate, StopReason,
ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
ToolCallUpdateFields, ToolKind,
};
use rho_sdk::{
tool::{OperationKind, ToolMetadata, ToolOutput},
RunEvent, ToolCompletion,
};
use serde_json::Value;
use crate::{
display_transcript::{DisplayRow, DisplayTranscript},
presentation::NotificationTone,
};
const PROVIDER_STREAM_RESET_NOTICE: &str = "[provider response discarded; retrying]";
const UNNAMED_TOOL_TITLE: &str = "tool";
#[path = "events_replay.rs"]
mod replay;
pub(super) struct EventMapper {
proposed: HashMap<String, rho_sdk::model::ToolCall>,
announced: BTreeMap<String, CallState>,
}
#[derive(Clone, Copy, PartialEq)]
enum CallState {
Open,
Finished,
}
impl EventMapper {
pub(super) fn new() -> Self {
Self {
proposed: HashMap::new(),
announced: BTreeMap::new(),
}
}
pub(super) fn map_event(
&mut self,
session_id: &SessionId,
event: &rho_sdk::RunEvent,
) -> Vec<SessionNotification> {
let update = match event {
RunEvent::AssistantTextDelta { text } => {
Some(SessionUpdate::AgentMessageChunk(text_chunk(text)))
}
RunEvent::ReasoningDelta { text } | RunEvent::ReasoningSummaryDelta { text } => {
Some(SessionUpdate::AgentThoughtChunk(text_chunk(text)))
}
RunEvent::ToolProposed { call } => {
if !call.id.is_empty() {
self.proposed.insert(call.id.clone(), call.clone());
}
None
}
RunEvent::ToolStarted {
call_id,
name,
metadata,
} => {
let proposed = self
.proposed
.remove(call_id.as_str())
.map(|call| call.arguments);
self.announced
.insert(call_id.as_str().to_owned(), CallState::Open);
Some(SessionUpdate::ToolCall(started_tool_call(
call_id, name, metadata, proposed,
)))
}
RunEvent::ToolUpdated { call_id, progress } => self.tool_update(
call_id,
CallState::Open,
ToolCallStatus::InProgress,
vec![ToolCallContent::from(progress.text())],
),
RunEvent::ToolFinished { call_id, result } => {
let (status, content) = finished_content(result);
self.tool_update(call_id, CallState::Finished, status, content)
}
RunEvent::ProviderStreamReset { .. } => {
self.proposed.clear();
Some(SessionUpdate::AgentThoughtChunk(text_chunk(
PROVIDER_STREAM_RESET_NOTICE,
)))
}
RunEvent::Completed { .. } | RunEvent::Cancelled { .. } | RunEvent::Failed { .. } => {
self.proposed.clear();
return self.close_open_calls(session_id);
}
RunEvent::Started { .. }
| RunEvent::StepStarted { .. }
| RunEvent::ToolCallUpdated { .. }
| RunEvent::UsageUpdated { .. }
| RunEvent::ProviderContextUpdated { .. }
| RunEvent::HostInputRequested { .. }
| RunEvent::CompactionStarted { .. }
| RunEvent::CompactionCompleted { .. }
| RunEvent::SteeringApplied { .. }
| RunEvent::SteeringDelivered { .. }
| RunEvent::ProviderDiagnostic { .. }
| RunEvent::ToolHostInputRequested { .. }
| RunEvent::WebSearch { .. }
| RunEvent::ProviderRequestRetry
| RunEvent::ModelCallCompleted { .. }
| RunEvent::HostedToolActivity { .. }
| RunEvent::ProviderServiceTierFallback { .. } => None,
_ => None,
};
update
.map(|update| notify(session_id, update))
.into_iter()
.collect()
}
pub(super) fn close_open_calls(&mut self, session_id: &SessionId) -> Vec<SessionNotification> {
self.announced
.iter_mut()
.filter(|(_, state)| **state == CallState::Open)
.map(|(call_id, state)| {
*state = CallState::Finished;
notify(
session_id,
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(call_id.as_str()),
ToolCallUpdateFields::new().status(ToolCallStatus::Failed),
)),
)
})
.collect()
}
fn tool_update(
&mut self,
call_id: &rho_sdk::ToolCallId,
next: CallState,
status: ToolCallStatus,
content: Vec<ToolCallContent>,
) -> Option<SessionUpdate> {
let id = call_id.as_str();
if let Some(state) = self.announced.get_mut(id) {
if *state == CallState::Finished && next == CallState::Open {
return None;
}
*state = next;
return Some(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(id),
ToolCallUpdateFields::new().status(status).content(content),
)));
}
self.announced.insert(id.to_owned(), next);
let proposed = self.proposed.remove(id);
let title = proposed
.as_ref()
.map_or(UNNAMED_TOOL_TITLE, |call| call.name.as_str());
let mut tool = ToolCall::new(ToolCallId::new(id), title)
.kind(ToolKind::Other)
.status(status)
.content(content);
if let Some(call) = proposed {
tool = tool.name(call.name).raw_input(call.arguments);
}
Some(SessionUpdate::ToolCall(tool))
}
pub(super) fn map_stop(outcome: &rho_sdk::RunOutcome) -> StopReason {
map_sdk_stop_reason(outcome.stop_reason())
}
pub(super) fn replay_history(
session_id: &SessionId,
messages: &[rho_sdk::model::Message],
) -> Vec<SessionNotification> {
replay::history(session_id, messages)
}
pub(super) fn agent_notice(session_id: &SessionId, text: &str) -> SessionNotification {
notify(
session_id,
SessionUpdate::AgentMessageChunk(text_chunk(text)),
)
}
pub(super) fn display_transcript(
session_id: &SessionId,
transcript: &DisplayTranscript,
) -> Vec<SessionNotification> {
transcript
.0
.iter()
.map(|row| notify(session_id, SessionUpdate::ToolCall(display_row_call(row))))
.collect()
}
}
fn map_sdk_stop_reason(reason: rho_sdk::StopReason) -> StopReason {
match reason {
rho_sdk::StopReason::EndTurn => StopReason::EndTurn,
rho_sdk::StopReason::MaxSteps => StopReason::MaxTurnRequests,
_ => StopReason::EndTurn,
}
}
fn notify(session_id: &SessionId, update: SessionUpdate) -> SessionNotification {
SessionNotification::new(session_id.clone(), update)
}
fn text_chunk(text: impl Into<String>) -> ContentChunk {
ContentChunk::new(ContentBlock::from(text.into()))
}
fn started_tool_call(
call_id: &rho_sdk::ToolCallId,
name: &str,
metadata: &ToolMetadata,
proposed: Option<Value>,
) -> ToolCall {
let title = tool_title(name, metadata);
let mut tool = ToolCall::new(ToolCallId::new(call_id.as_str()), title)
.name(name.to_owned())
.kind(tool_kind(metadata))
.status(ToolCallStatus::InProgress)
.locations(tool_locations(metadata.affected_paths()));
if let Some(arguments) = proposed {
tool = tool.raw_input(arguments);
}
tool
}
fn tool_title(name: &str, metadata: &ToolMetadata) -> String {
match metadata.command_summary_text() {
Some(summary) if !summary.is_empty() => format!("{name}: {summary}"),
_ => name.to_string(),
}
}
fn tool_kind(metadata: &ToolMetadata) -> ToolKind {
match metadata.operation_kind() {
Some(OperationKind::Read) => ToolKind::Read,
Some(OperationKind::Write) => ToolKind::Edit,
Some(OperationKind::Execute) => ToolKind::Execute,
Some(OperationKind::Network) => ToolKind::Fetch,
_ => ToolKind::Other,
}
}
fn tool_locations(paths: &[std::path::PathBuf]) -> Vec<ToolCallLocation> {
paths.iter().cloned().map(ToolCallLocation::new).collect()
}
fn finished_content(result: &ToolCompletion) -> (ToolCallStatus, Vec<ToolCallContent>) {
match result {
ToolCompletion::CompletedFailure(output) => {
(ToolCallStatus::Failed, success_content(output))
}
ToolCompletion::Success(output) => (ToolCallStatus::Completed, success_content(output)),
ToolCompletion::Failure(failure) => (
ToolCallStatus::Failed,
vec![ToolCallContent::from(failure.message())],
),
ToolCompletion::Unavailable => (
ToolCallStatus::Failed,
vec![ToolCallContent::from("tool is unavailable")],
),
_ => (ToolCallStatus::Failed, Vec::new()),
}
}
fn success_content(output: &ToolOutput) -> Vec<ToolCallContent> {
let mut content = Vec::new();
if !output.content().is_empty() {
content.push(ToolCallContent::from(output.content()));
}
let metadata = output.presentation();
if metadata.file_changes().is_empty() {
if let Some(diff) = metadata.unified_diff() {
content.push(ToolCallContent::from(diff));
}
} else {
content.extend(metadata.file_changes().iter().map(|change| {
ToolCallContent::Diff(
Diff::new(change.path(), change.new_text())
.old_text(change.old_text().map(str::to_owned)),
)
}));
}
content
}
fn display_row_call(row: &DisplayRow) -> ToolCall {
let status = match row {
DisplayRow::Notification(card) => match card.tone {
NotificationTone::Error => ToolCallStatus::Failed,
NotificationTone::Neutral
| NotificationTone::Accent
| NotificationTone::Success
| NotificationTone::Warning => ToolCallStatus::Completed,
},
DisplayRow::Notice(_) => ToolCallStatus::Completed,
};
let text = row.card_text();
let id = format!("rho-notice-{}", uuid::Uuid::new_v4());
let mut call = ToolCall::new(ToolCallId::new(id), text.title)
.kind(ToolKind::Other)
.status(status);
if !text.plain.trim().is_empty() {
call = call
.content(vec![ToolCallContent::from(text.markdown)])
.raw_output(Value::String(text.plain));
}
call
}
#[cfg(test)]
#[path = "events_tests.rs"]
mod tests;