use crate::{
app::interactive_presenter::InteractiveToolPresenter,
questionnaire::{QuestionnaireAnswer, QuestionnaireQuestionKind, QuestionnaireResponse},
tool::ToolDisplayStyle,
};
use rho_sdk::{
model::{ContextUsage, ModelUsage},
HostInputRequest, HostInputResponse, RunEvent, PROVIDER_ACTIVITY_INVALID_RESPONSE_RETRY,
PROVIDER_ACTIVITY_WEB_SEARCH,
};
use super::questionnaire::{QuestionnaireChoice, QuestionnaireQuestion, QuestionnaireRequest};
#[derive(Clone, Debug)]
pub(super) enum ViewModelEvent {
RunStarted,
StepStarted(usize),
SteeringApplied(Vec<rho_sdk::SteeringId>),
ToolStarted {
display_lines: Vec<String>,
},
ProviderStreamReset,
OutputDelta(String),
ReasoningDelta(String),
ContextUsage(ContextUsage),
Usage(ModelUsage),
ToolUpdated {
display_lines: Vec<String>,
},
ToolCallUpdated {
display_lines: Vec<String>,
},
ToolFinished {
ok: bool,
display_style: ToolDisplayStyle,
display_lines: Vec<String>,
},
}
#[derive(Clone, Debug)]
pub(super) enum ViewEvent {
Update(ViewModelEvent),
Questionnaire(HostInputRequest),
Notice(String),
Completed,
Cancelled,
Failed(String),
Ignored,
}
#[derive(Default)]
pub(super) struct SdkEventAdapter {
presenter: Option<InteractiveToolPresenter>,
}
impl SdkEventAdapter {
pub(super) fn new(cwd: std::path::PathBuf) -> Self {
Self {
presenter: Some(InteractiveToolPresenter::new(cwd)),
}
}
fn presenter(&mut self) -> &mut InteractiveToolPresenter {
self.presenter
.get_or_insert_with(|| InteractiveToolPresenter::new(std::path::PathBuf::new()))
}
pub(super) fn translate(&mut self, event: RunEvent) -> ViewEvent {
match event {
RunEvent::Started { .. } => ViewEvent::Update(ViewModelEvent::RunStarted),
RunEvent::StepStarted { step } => {
self.presenter().step_started();
ViewEvent::Update(ViewModelEvent::StepStarted(step))
}
RunEvent::SteeringApplied { ids } => {
ViewEvent::Update(ViewModelEvent::SteeringApplied(ids))
}
RunEvent::AssistantTextDelta { text } => {
ViewEvent::Update(ViewModelEvent::OutputDelta(text))
}
RunEvent::ReasoningDelta { text } | RunEvent::ReasoningSummaryDelta { text } => {
ViewEvent::Update(ViewModelEvent::ReasoningDelta(text))
}
RunEvent::ToolCallUpdated {
index,
name,
arguments_delta,
..
} => self
.presenter()
.preview(index, name, &arguments_delta)
.map_or(ViewEvent::Ignored, |display_lines| {
ViewEvent::Update(ViewModelEvent::ToolCallUpdated { display_lines })
}),
RunEvent::ToolProposed { call } => {
let display_lines = self.presenter().proposed(call);
ViewEvent::Update(ViewModelEvent::ToolCallUpdated { display_lines })
}
RunEvent::ToolStarted {
call_id,
name,
metadata,
} => {
let display_lines = self
.presenter()
.started(call_id, name, metadata)
.display_lines;
ViewEvent::Update(ViewModelEvent::ToolStarted { display_lines })
}
RunEvent::ToolUpdated { call_id, progress } => {
let display_lines = self.presenter().updated(&call_id, &progress);
ViewEvent::Update(ViewModelEvent::ToolUpdated { display_lines })
}
RunEvent::ToolFinished { call_id, result } => {
let (ok, presented) = self.presenter().finished(&call_id, result);
ViewEvent::Update(ViewModelEvent::ToolFinished {
ok,
display_style: presented.display_style,
display_lines: presented.display_lines,
})
}
RunEvent::UsageUpdated { usage } => ViewEvent::Update(ViewModelEvent::Usage(usage)),
RunEvent::ProviderActivity { kind, detail } => {
if kind == PROVIDER_ACTIVITY_INVALID_RESPONSE_RETRY {
self.presenter().step_started();
ViewEvent::Update(ViewModelEvent::ProviderStreamReset)
} else if kind == PROVIDER_ACTIVITY_WEB_SEARCH {
ViewEvent::Update(ViewModelEvent::ToolFinished {
ok: true,
display_style: ToolDisplayStyle::web(),
display_lines: vec![format!("web search: {detail}")],
})
} else {
ViewEvent::Notice(format!("{kind}: {detail}"))
}
}
RunEvent::ProviderContextUpdated { .. } => ViewEvent::Ignored,
RunEvent::ProviderDiagnostic { detail } => {
ViewEvent::Notice(format!("provider diagnostic:\n{}", detail.as_str()))
}
RunEvent::HostInputRequested { request } => ViewEvent::Questionnaire(request),
RunEvent::CompactionStarted { .. } => {
ViewEvent::Notice("compacting conversation context".into())
}
RunEvent::CompactionCompleted { outcome, .. } => ViewEvent::Notice(format!(
"compacted conversation context ({} to {} messages)",
outcome.previous_messages(),
outcome.current_messages()
)),
RunEvent::Completed { .. } => ViewEvent::Completed,
RunEvent::Cancelled { .. } => ViewEvent::Cancelled,
RunEvent::Failed { message, .. } => ViewEvent::Failed(message),
_ => ViewEvent::Ignored,
}
}
}
pub(super) fn questionnaire_request(request: &HostInputRequest) -> QuestionnaireRequest {
QuestionnaireRequest {
title: (!request.title().is_empty()).then(|| request.title().to_string()),
reason: None,
questions: request
.questions()
.iter()
.map(|question| {
let choices = question
.choices()
.iter()
.map(|choice| QuestionnaireChoice::new(choice.value(), choice.label()))
.collect::<Vec<_>>();
QuestionnaireQuestion {
id: question.id().to_string(),
question: question.prompt().to_string(),
help: question.help_text().map(str::to_string),
default: question.default_value_ref().cloned(),
kind: questionnaire_kind(question),
required: question.is_required(),
choices,
allow_other: question.permits_other(),
}
})
.collect(),
}
}
fn questionnaire_kind(question: &rho_sdk::HostQuestion) -> QuestionnaireQuestionKind {
match question.selection() {
rho_sdk::SelectionMode::Many => QuestionnaireQuestionKind::MultiSelect,
rho_sdk::SelectionMode::One if is_yes_no_question(question) => {
QuestionnaireQuestionKind::Confirm
}
rho_sdk::SelectionMode::One => QuestionnaireQuestionKind::Choice,
_ => QuestionnaireQuestionKind::Choice,
}
}
fn is_yes_no_question(question: &rho_sdk::HostQuestion) -> bool {
matches!(
question.choices(),
[yes, no]
if yes.value().eq_ignore_ascii_case("yes")
&& no.value().eq_ignore_ascii_case("no")
)
}
pub(super) fn host_response(response: QuestionnaireResponse) -> HostInputResponse {
response.answers.into_iter().fold(
HostInputResponse::new(),
|response, QuestionnaireAnswer { id, answer }| match answer {
serde_json::Value::Null => response,
serde_json::Value::Array(values) if values.is_empty() => response,
serde_json::Value::Array(values) => {
response.answer(id, values.into_iter().map(answer_text).collect::<Vec<_>>())
}
value => response.answer(id, vec![answer_text(value)]),
},
)
}
fn answer_text(value: serde_json::Value) -> String {
match value {
serde_json::Value::String(value) => value,
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
value => value.to_string(),
}
}
#[cfg(test)]
#[path = "event_adapter_tests.rs"]
mod tests;