use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Approval {
pub id: String,
pub tool: String,
pub input: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Decision {
Allow,
Deny {
reason: String,
},
}
impl Decision {
#[must_use]
pub fn deny() -> Decision {
Decision::Deny {
reason: "the user declined this action".into(),
}
}
pub(crate) fn wire(&self, id: &str) -> String {
let response = match self {
Decision::Allow => serde_json::json!({"behavior": "allow"}),
Decision::Deny { reason } => {
serde_json::json!({"behavior": "deny", "message": reason})
}
};
format!(
"{}\n",
serde_json::json!({
"type": "control_response",
"response": {
"request_id": id,
"subtype": "success",
"response": response,
},
})
)
}
}
pub(crate) fn handshake() -> String {
format!(
"{}\n",
serde_json::json!({
"type": "control_request",
"request_id": "agent-abstraction-init",
"request": {"subtype": "initialize"},
})
)
}
pub(crate) fn user_message(prompt: &str) -> String {
format!(
"{}\n",
serde_json::json!({
"type": "user",
"message": {"role": "user", "content": [{"type": "text", "text": prompt}]},
})
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_decision_serializes_to_the_shape_claude_answers() {
let allow: Value =
serde_json::from_str(Decision::Allow.wire("req-1").trim()).expect("json");
assert_eq!(allow["type"], "control_response");
assert_eq!(allow["response"]["request_id"], "req-1");
assert_eq!(allow["response"]["subtype"], "success");
assert_eq!(allow["response"]["response"]["behavior"], "allow");
let deny: Value =
serde_json::from_str(Decision::deny().wire("req-2").trim()).expect("json");
assert_eq!(deny["response"]["response"]["behavior"], "deny");
assert!(
deny["response"]["response"]["message"]
.as_str()
.is_some_and(|m| !m.is_empty()),
"a denial should carry a reason the model can read"
);
}
#[test]
fn an_awkward_reason_stays_one_json_line() {
let decision = Decision::Deny {
reason: "no \"rm -rf\" here\nand no newlines either".into(),
};
let wire = decision.wire("req-3");
assert_eq!(
wire.matches('\n').count(),
1,
"exactly one trailing newline"
);
let parsed: Value = serde_json::from_str(wire.trim()).expect("still valid json");
assert!(
parsed["response"]["response"]["message"]
.as_str()
.expect("message")
.contains("rm -rf"),
"the reason survives intact"
);
}
#[test]
fn a_prompt_with_control_characters_survives_the_wrapper() {
let wire = user_message("say \"ok\"\nthen stop");
assert_eq!(wire.matches('\n').count(), 1);
let parsed: Value = serde_json::from_str(wire.trim()).expect("json");
assert_eq!(parsed["type"], "user");
assert_eq!(
parsed["message"]["content"][0]["text"],
"say \"ok\"\nthen stop"
);
}
#[test]
fn the_handshake_is_one_valid_line() {
let wire = handshake();
assert_eq!(wire.matches('\n').count(), 1);
let parsed: Value = serde_json::from_str(wire.trim()).expect("json");
assert_eq!(parsed["type"], "control_request");
assert_eq!(parsed["request"]["subtype"], "initialize");
}
}