use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HostAction {
pub id: String,
#[serde(rename = "type")]
pub action_type: String,
#[serde(default)]
pub params: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution: Option<HostActionExecution>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, Value>,
}
impl HostAction {
pub fn new(
id: impl Into<String>,
action_type: impl Into<String>,
params: impl Serialize,
) -> serde_json::Result<Self> {
Ok(Self {
id: id.into(),
action_type: action_type.into(),
params: serde_json::to_value(params)?,
label: None,
execution: None,
metadata: BTreeMap::new(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HostActionExecution {
Manual,
AutoIfAllowed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HostActionOutcome {
pub action_id: String,
pub status: HostActionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<HostActionError>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HostActionStatus {
Completed,
Denied,
Unsupported,
Invalid,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostActionError {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolContent {
Text { text: String },
Markdown { markdown: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ToolResult {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<ToolContent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<HostAction>,
}
impl ToolResult {
pub fn text(text: impl Into<String>) -> Self {
Self {
content: vec![ToolContent::Text { text: text.into() }],
actions: Vec::new(),
}
}
pub fn with_action(mut self, action: HostAction) -> Self {
self.actions.push(action);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_action_status_invalid_serializes_as_snake_case() {
let status = serde_json::to_value(HostActionStatus::Invalid).unwrap();
assert_eq!(status, serde_json::json!("invalid"));
}
#[test]
fn host_action_outcome_round_trips() {
let json = serde_json::json!({
"action_id": "open-reader",
"status": "completed",
"result": {
"terminal_id": "term_123",
"backend": "zellij",
"actual_placement": "background_session"
}
});
let outcome: HostActionOutcome = serde_json::from_value(json.clone()).unwrap();
assert_eq!(outcome.action_id, "open-reader");
assert_eq!(outcome.status, HostActionStatus::Completed);
assert_eq!(outcome.result.as_ref().unwrap()["terminal_id"], "term_123");
assert_eq!(serde_json::to_value(outcome).unwrap(), json);
}
#[test]
fn host_action_outcome_acceptance_example_round_trips() {
let json = serde_json::json!({
"action_id": "open-reader",
"status": "completed",
"result": {
"terminal_id": "term_123",
"backend": "zellij",
"actual_placement": "background_session"
}
});
let outcome: HostActionOutcome = serde_json::from_value(json.clone()).unwrap();
assert_eq!(serde_json::to_value(outcome).unwrap(), json);
}
#[test]
fn tool_result_carries_actions() {
let action = HostAction::new(
"open-reader",
"terminal.create@1",
serde_json::json!({"command": "bookokrat"}),
)
.unwrap();
let result = ToolResult::text("Opening reader").with_action(action);
let json = serde_json::to_value(result).unwrap();
assert_eq!(json["actions"][0]["type"], "terminal.create@1");
assert_eq!(json["content"][0]["text"], "Opening reader");
}
}