#![cfg_attr(test, allow(clippy::expect_used))]
use motosan_agent_tool::ToolResult;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize)]
pub enum ProgressChunk {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Status(String),
}
impl From<crate::tools::ToolProgressChunk> for ProgressChunk {
fn from(c: crate::tools::ToolProgressChunk) -> Self {
use crate::tools::ToolProgressChunk as TPC;
match c {
TPC::Stdout(b) => Self::Stdout(b),
TPC::Stderr(b) => Self::Stderr(b),
TPC::Status(s) => Self::Status(s),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionResolution {
pub tool: String,
pub args: serde_json::Value,
pub choice: PermissionChoice,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionChoice {
AllowOnce,
AllowSession,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum Command {
SendUserMessage(String),
CancelAgent,
Quit,
ResolvePermission(PermissionResolution),
RunInlineBash {
command: String,
send_to_llm: bool,
},
Compact,
NewSession,
CloneSession,
GetMessages,
GetState,
SwitchModel(crate::model::ModelId),
LoadSession(String),
ForkFrom {
from: String,
message: String,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum UiEvent {
AgentTurnStarted,
AgentThinking,
AgentTextDelta(String),
AgentMessageComplete(String),
ToolCallStarted {
id: String,
name: String,
args: Value,
},
ToolCallProgress {
id: String,
chunk: ProgressChunk,
},
ToolCallCompleted {
id: String,
result: UiToolResult,
},
AgentTurnComplete,
PermissionRequested {
tool: String,
args: serde_json::Value,
#[serde(skip)]
resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
},
InlineBashOutput {
command: String,
output: String,
},
SessionReplaced(Vec<motosan_agent_loop::Message>),
ModelSwitched(crate::model::ModelId),
ForkCandidates(Vec<(String, String)>),
BranchTree(
#[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
motosan_agent_loop::BranchTree,
),
MessagesSnapshot(Vec<motosan_agent_loop::Message>),
StateSnapshot {
session_id: String,
model: String,
active_turn: bool,
},
Error(String),
}
#[derive(Debug, Clone, Serialize)]
pub struct UiToolResult {
pub is_error: bool,
pub text: String,
}
impl From<&ToolResult> for UiToolResult {
fn from(r: &ToolResult) -> Self {
Self {
is_error: r.is_error,
text: format!("{r:?}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compact_command_is_constructible() {
let c = Command::Compact;
assert_eq!(c, Command::Compact);
assert!(format!("{c:?}").contains("Compact"));
}
#[test]
fn d2_command_and_event_variants_construct() {
let _ = Command::NewSession;
let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
let _ = Command::LoadSession("sess-id".into());
let e = UiEvent::SessionReplaced(Vec::new());
assert!(format!("{e:?}").contains("SessionReplaced"));
let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
assert!(format!("{e:?}").contains("ModelSwitched"));
}
#[test]
fn clone_session_command_constructs() {
let c = Command::CloneSession;
assert!(format!("{c:?}").contains("CloneSession"));
}
#[test]
fn read_query_commands_round_trip() {
for c in [Command::GetMessages, Command::GetState] {
let line = serde_json::to_string(&c).expect("serialize");
assert!(line.contains("\"type\":"), "{line}");
let back: Command = serde_json::from_str(&line).expect("deserialize");
assert_eq!(c, back);
}
assert_eq!(
serde_json::to_string(&Command::GetMessages).expect("ser"),
r#"{"type":"get_messages"}"#
);
}
#[test]
fn fork_protocol_variants_construct() {
let c = Command::ForkFrom {
from: "e1".into(),
message: "hi".into(),
};
assert!(format!("{c:?}").contains("ForkFrom"));
let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
assert!(format!("{e:?}").contains("ForkCandidates"));
}
#[test]
fn branch_tree_event_constructs() {
let tree = motosan_agent_loop::BranchTree {
nodes: Vec::new(),
root: None,
active_leaf: None,
};
let e = UiEvent::BranchTree(tree);
assert!(format!("{e:?}").contains("BranchTree"));
}
#[test]
fn run_inline_bash_command_is_constructible() {
let c = Command::RunInlineBash {
command: "ls".into(),
send_to_llm: true,
};
assert!(format!("{c:?}").contains("RunInlineBash"));
}
#[test]
fn permission_protocol_variants_are_constructible() {
let command = Command::ResolvePermission(PermissionResolution {
tool: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
choice: PermissionChoice::AllowSession,
});
assert!(format!("{command:?}").contains("ResolvePermission"));
assert!(format!("{command:?}").contains("AllowSession"));
let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
let event = UiEvent::PermissionRequested {
tool: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
resolver,
};
assert!(format!("{event:?}").contains("PermissionRequested"));
}
#[test]
fn command_round_trips_through_json() {
let cases = vec![
Command::SendUserMessage("hi".into()),
Command::CancelAgent,
Command::Quit,
Command::Compact,
Command::NewSession,
Command::CloneSession,
Command::RunInlineBash {
command: "ls".into(),
send_to_llm: true,
},
Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
Command::LoadSession("sess-1".into()),
Command::ForkFrom {
from: "e1".into(),
message: "hi".into(),
},
Command::ResolvePermission(PermissionResolution {
tool: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
choice: PermissionChoice::AllowSession,
}),
];
for c in cases {
let line = serde_json::to_string(&c).expect("serialize");
assert!(line.contains("\"type\":"), "missing type tag: {line}");
let back: Command = serde_json::from_str(&line).expect("deserialize");
assert_eq!(c, back, "round-trip mismatch for {line}");
}
}
#[test]
fn command_uses_adjacent_tagging() {
let line =
serde_json::to_string(&Command::SendUserMessage("hi".into())).expect("serialize");
assert_eq!(line, r#"{"type":"send_user_message","payload":"hi"}"#);
let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
}
#[test]
fn snapshot_events_serialize_with_type_tag() {
let m = UiEvent::MessagesSnapshot(Vec::new());
let line = serde_json::to_string(&m).expect("serialize");
assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
let s = UiEvent::StateSnapshot {
session_id: "sess-1".into(),
model: "claude-opus-4-7".into(),
active_turn: false,
};
let line = serde_json::to_string(&s).expect("serialize");
assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
assert!(line.contains(r#""active_turn":false"#), "{line}");
}
#[test]
fn ui_events_serialize_with_type_tag() {
let events = vec![
UiEvent::AgentTurnStarted,
UiEvent::AgentThinking,
UiEvent::AgentTextDelta("hi".into()),
UiEvent::AgentMessageComplete("done".into()),
UiEvent::AgentTurnComplete,
UiEvent::InlineBashOutput {
command: "ls".into(),
output: "x".into(),
},
UiEvent::SessionReplaced(Vec::new()),
UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
UiEvent::Error("bad".into()),
UiEvent::ToolCallStarted {
id: "t1".into(),
name: "bash".into(),
args: serde_json::json!("ls"),
},
UiEvent::ToolCallProgress {
id: "t1".into(),
chunk: ProgressChunk::Status("running".into()),
},
UiEvent::ToolCallCompleted {
id: "t1".into(),
result: UiToolResult {
is_error: false,
text: "ok".into(),
},
},
];
for e in &events {
let line = serde_json::to_string(e).expect("serialize");
assert!(line.contains("\"type\":"), "missing type tag: {line}");
}
}
#[test]
fn permission_requested_serializes_without_the_resolver() {
let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
let e = UiEvent::PermissionRequested {
tool: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
resolver,
};
let line = serde_json::to_string(&e).expect("serialize");
assert!(line.contains(r#""type":"permission_requested""#), "{line}");
assert!(!line.contains("resolver"), "resolver leaked: {line}");
}
#[test]
fn branch_tree_event_serializes_via_mapping() {
let tree = motosan_agent_loop::BranchTree {
nodes: vec![motosan_agent_loop::BranchNode {
id: "n0".into(),
parent: None,
children: vec![],
label: "root".into(),
}],
root: Some(0),
active_leaf: Some(0),
};
let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
assert!(line.contains(r#""type":"branch_tree""#), "{line}");
assert!(line.contains(r#""label":"root""#), "{line}");
}
}