use serde_json::{json, Value};
pub(crate) const SOURCE: &str = "custom:newt";
pub(crate) const AGENT: &str = "newt";
const MAX_MESSAGE_CHARS: usize = 40;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PaneAgentState {
Idle,
Working,
Blocked,
}
impl PaneAgentState {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Working => "working",
Self::Blocked => "blocked",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SessionStartSource {
Startup,
}
impl SessionStartSource {
fn as_str(self) -> &'static str {
match self {
Self::Startup => "startup",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Call {
pub(crate) method: &'static str,
pub(crate) params: Value,
}
impl Call {
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn encode(&self, id: u64) -> Option<Vec<u8>> {
let request = json!({
"id": format!("{SOURCE}:{id}"),
"method": self.method,
"params": self.params,
});
let mut line = serde_json::to_vec(&request).ok()?;
line.push(b'\n');
Some(line)
}
}
fn sanitize_message(message: &str) -> Option<String> {
let cleaned: String = message
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ' '))
.take(MAX_MESSAGE_CHARS)
.collect();
let cleaned = cleaned.trim().to_string();
cleaned
.starts_with(|c: char| c.is_ascii_alphabetic())
.then_some(cleaned)
}
pub(crate) fn report_agent(pane: &str, state: PaneAgentState, message: Option<&str>) -> Call {
let mut params = json!({
"pane_id": pane,
"source": SOURCE,
"agent": AGENT,
"state": state.as_str(),
});
if let Some(message) = message.and_then(sanitize_message) {
params["message"] = Value::String(message);
}
Call {
method: "pane.report_agent",
params,
}
}
pub(crate) fn report_agent_session(
pane: &str,
session_id: &str,
start: SessionStartSource,
) -> Call {
Call {
method: "pane.report_agent_session",
params: json!({
"pane_id": pane,
"source": SOURCE,
"agent": AGENT,
"agent_session_id": session_id,
"session_start_source": start.as_str(),
}),
}
}
pub(crate) fn report_metadata_title(pane: &str, title: &str) -> Call {
Call {
method: "pane.report_metadata",
params: json!({
"pane_id": pane,
"source": SOURCE,
"agent": AGENT,
"title": title,
}),
}
}
pub(crate) fn release_agent(pane: &str) -> Call {
Call {
method: "pane.release_agent",
params: json!({
"pane_id": pane,
"source": SOURCE,
"agent": AGENT,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoded_request_is_one_json_line_in_herdr_shape() {
let line = report_agent("w1:p2", PaneAgentState::Working, Some("read_file"))
.encode(7)
.unwrap();
assert_eq!(*line.last().unwrap(), b'\n');
assert_eq!(
line.iter().filter(|b| **b == b'\n').count(),
1,
"exactly one frame per call"
);
let parsed: Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap();
assert_eq!(parsed["id"], "custom:newt:7");
assert_eq!(parsed["method"], "pane.report_agent");
assert_eq!(parsed["params"]["pane_id"], "w1:p2");
assert_eq!(parsed["params"]["source"], "custom:newt");
assert_eq!(parsed["params"]["agent"], "newt");
assert_eq!(parsed["params"]["state"], "working");
assert_eq!(parsed["params"]["message"], "read_file");
}
#[test]
fn release_identity_matches_report_identity() {
let identity = |call: &Call| {
(
call.params["pane_id"].clone(),
call.params["source"].clone(),
call.params["agent"].clone(),
)
};
assert_eq!(
identity(&report_agent("w1:p2", PaneAgentState::Idle, None)),
identity(&release_agent("w1:p2"))
);
}
#[test]
fn no_call_carries_a_sequence_number() {
for call in [
report_agent("p", PaneAgentState::Working, None),
report_agent_session("p", "s", SessionStartSource::Startup),
report_metadata_title("p", "repo"),
release_agent("p"),
] {
assert!(
call.params.get("seq").is_none(),
"{} must not carry seq",
call.method
);
}
}
#[test]
fn messages_are_sanitized_and_bounded() {
assert_eq!(sanitize_message("read_file"), Some("read_file".into()));
assert_eq!(
sanitize_message("drop\ntable; rm -rf /"),
Some("droptable rm -rf".into())
);
assert_eq!(sanitize_message("…"), None);
assert_eq!(sanitize_message(" "), None);
assert_eq!(
sanitize_message("\u{1b}[31m"),
None,
"the residue of a stripped escape is not a name"
);
assert_eq!(sanitize_message(&"x".repeat(200)).unwrap().len(), 40);
let call = report_agent("p", PaneAgentState::Working, Some("\u{1b}[31m"));
assert!(
call.params.get("message").is_none(),
"an all-control-character message is omitted, not emitted empty"
);
}
}