use aion_core::{
ActivityEvent, ActivityEventKind, AssistantCommand, AssistantConfigChoice,
AssistantConfigOption, AssistantConfigValue, AssistantPermissionDecision,
AssistantSessionEvent, AssistantToolCallStatus, MessageRole, ProgressDetail,
};
use serde_json::Value;
use std::collections::HashMap;
const PERMISSION_REQUEST_SOURCE: &str = "session/request_permission";
const PERMISSION_DECISION_SOURCE: &str = "session/request_permission/decision";
const AVAILABLE_COMMANDS_SOURCE: &str = "session/update/available_commands_update";
const CONFIG_OPTIONS_SOURCE: &str = "session/update/config_option_update";
pub(crate) struct TurnFrames {
turn_id: String,
tool_names: HashMap<String, String>,
pending_permission: Option<Value>,
}
impl TurnFrames {
pub(crate) fn new(turn_id: impl Into<String>) -> Self {
Self {
turn_id: turn_id.into(),
tool_names: HashMap::new(),
pending_permission: None,
}
}
pub(crate) fn translate(&mut self, event: ActivityEvent) -> Vec<AssistantSessionEvent> {
match event.kind {
ActivityEventKind::Delta { text_fragment, .. } => {
vec![AssistantSessionEvent::Delta {
turn_id: self.turn_id.clone(),
text: text_fragment,
}]
}
ActivityEventKind::Message {
role: MessageRole::Assistant,
..
}
| ActivityEventKind::Stop { .. } => Vec::new(),
ActivityEventKind::Message { role, text } => {
vec![AssistantSessionEvent::Raw {
turn_id: Some(self.turn_id.clone()),
source: "message".to_owned(),
value: serde_json::json!({ "role": role, "text": text }),
}]
}
ActivityEventKind::Progress {
detail: ProgressDetail::Thinking { text, .. },
} => vec![AssistantSessionEvent::Thought {
turn_id: self.turn_id.clone(),
text,
}],
ActivityEventKind::Progress { detail } => {
vec![AssistantSessionEvent::Raw {
turn_id: Some(self.turn_id.clone()),
source: "progress".to_owned(),
value: serde_json::to_value(detail).unwrap_or(Value::Null),
}]
}
ActivityEventKind::ToolCall {
tool,
call_id,
input,
} => {
self.tool_names.insert(call_id.clone(), tool.clone());
vec![AssistantSessionEvent::ToolCall {
turn_id: self.turn_id.clone(),
call_id,
name: tool,
status: AssistantToolCallStatus::Started,
input: Some(input),
output: None,
}]
}
ActivityEventKind::ToolResult {
call_id,
output,
is_error,
} => {
let name = self
.tool_names
.get(&call_id)
.cloned()
.unwrap_or_else(|| UNMATCHED_TOOL.to_owned());
vec![AssistantSessionEvent::ToolCall {
turn_id: self.turn_id.clone(),
call_id,
name,
status: if is_error {
AssistantToolCallStatus::Failed
} else {
AssistantToolCallStatus::Completed
},
input: None,
output: Some(output),
}]
}
ActivityEventKind::Raw { source, value } => self.translate_raw(&source, value),
}
}
fn translate_raw(&mut self, source: &str, value: Value) -> Vec<AssistantSessionEvent> {
if source == AVAILABLE_COMMANDS_SOURCE {
return match available_commands(&value) {
Some(commands) => vec![AssistantSessionEvent::AvailableCommands { commands }],
None => vec![AssistantSessionEvent::Raw {
turn_id: Some(self.turn_id.clone()),
source: source.to_owned(),
value,
}],
};
}
if source == CONFIG_OPTIONS_SOURCE {
return match config_options(&value) {
Some(options) => vec![AssistantSessionEvent::ConfigOptions { options }],
None => vec![AssistantSessionEvent::Raw {
turn_id: Some(self.turn_id.clone()),
source: source.to_owned(),
value,
}],
};
}
if source == PERMISSION_REQUEST_SOURCE {
self.pending_permission = Some(value);
return Vec::new();
}
if source == PERMISSION_DECISION_SOURCE {
let request = self.pending_permission.take().unwrap_or(Value::Null);
let Some(decided) = decision_of(&value) else {
return vec![AssistantSessionEvent::Raw {
turn_id: Some(self.turn_id.clone()),
source: source.to_owned(),
value: serde_json::json!({ "request": request, "decision": value }),
}];
};
return vec![AssistantSessionEvent::PermissionAsk {
turn_id: self.turn_id.clone(),
request,
decided,
}];
}
vec![AssistantSessionEvent::Raw {
turn_id: Some(self.turn_id.clone()),
source: source.to_owned(),
value,
}]
}
pub(crate) fn flush(&mut self) -> Vec<AssistantSessionEvent> {
self.pending_permission
.take()
.map(|request| AssistantSessionEvent::Raw {
turn_id: Some(self.turn_id.clone()),
source: format!("{PERMISSION_REQUEST_SOURCE}/unanswered"),
value: request,
})
.into_iter()
.collect()
}
}
pub(crate) const UNMATCHED_TOOL: &str = "<unmatched tool call>";
fn available_commands(value: &Value) -> Option<Vec<AssistantCommand>> {
let listed = value.get("availableCommands")?.as_array()?;
let mut commands = Vec::with_capacity(listed.len());
for entry in listed {
let (Some(name), Some(description)) = (
entry.get("name").and_then(Value::as_str),
entry.get("description").and_then(Value::as_str),
) else {
tracing::warn!(
entry = %entry,
"an assistant harness advertised a command with no name or no description; it is \
not offered, and the rest of the advertisement stands"
);
continue;
};
commands.push(AssistantCommand {
name: name.to_owned(),
description: description.to_owned(),
input_hint: entry
.get("input")
.and_then(|input| input.get("hint"))
.and_then(Value::as_str)
.map(ToOwned::to_owned),
});
}
Some(commands)
}
pub(crate) fn config_options(value: &Value) -> Option<Vec<AssistantConfigOption>> {
let listed = value.get("configOptions")?.as_array()?;
let mut options = Vec::with_capacity(listed.len());
for entry in listed {
let (Some(id), Some(name)) = (
entry.get("id").and_then(Value::as_str),
entry.get("name").and_then(Value::as_str),
) else {
tracing::warn!(
entry = %entry,
"an assistant harness advertised a configuration option with no id or no name; \
it is not offered, and the rest of the advertisement stands"
);
continue;
};
let Some(value_read) = config_value(entry) else {
tracing::warn!(
option = id,
kind = entry
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("<absent>"),
"an assistant harness advertised a configuration option of a kind this server \
has no shape for; it is not offered, and the rest of the advertisement stands"
);
continue;
};
options.push(AssistantConfigOption {
id: id.to_owned(),
name: name.to_owned(),
description: entry
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
category: entry
.get("category")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
value: value_read,
});
}
Some(options)
}
fn config_value(entry: &Value) -> Option<AssistantConfigValue> {
match entry.get("type").and_then(Value::as_str) {
Some("select") => {
let current = entry
.get("currentValue")
.and_then(Value::as_str)?
.to_owned();
let listed = entry.get("options").and_then(Value::as_array)?;
let mut choices = Vec::with_capacity(listed.len());
for element in listed {
if let Some(grouped) = element.get("options").and_then(Value::as_array) {
let group = element.get("name").and_then(Value::as_str);
for inner in grouped {
push_choice(&mut choices, inner, group);
}
} else {
push_choice(&mut choices, element, None);
}
}
Some(AssistantConfigValue::Select { choices, current })
}
Some("boolean") => Some(AssistantConfigValue::Toggle {
current: entry.get("currentValue").and_then(Value::as_bool)?,
}),
_ => None,
}
}
fn push_choice(choices: &mut Vec<AssistantConfigChoice>, element: &Value, group: Option<&str>) {
let (Some(id), Some(name)) = (
element.get("value").and_then(Value::as_str),
element.get("name").and_then(Value::as_str),
) else {
tracing::warn!(
entry = %element,
"an assistant harness advertised a select choice with no value or no name; it is \
not offered, and the rest of the advertisement stands"
);
return;
};
choices.push(AssistantConfigChoice {
id: id.to_owned(),
name: name.to_owned(),
description: element
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
group: group.map(ToOwned::to_owned),
});
}
fn decision_of(value: &Value) -> Option<AssistantPermissionDecision> {
if value["outcome"]["outcome"] == "cancelled" {
return None;
}
Some(match value["policy"].as_str() {
Some("allow_once") => AssistantPermissionDecision::AllowOnce,
_ => AssistantPermissionDecision::Deny,
})
}
#[cfg(test)]
#[path = "frames_tests.rs"]
mod tests;