use crate::io::AgentIO;
use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub enum SseEvent {
Status { msg: String },
ToolCall { name: String, args: String },
ToolResult { preview: String, is_error: bool },
Error { msg: String },
#[allow(dead_code)]
Complete,
}
impl SseEvent {
pub fn event_name(&self) -> &'static str {
match self {
SseEvent::Status { .. } => "status",
SseEvent::ToolCall { .. } => "tool_call",
SseEvent::ToolResult { .. } => "tool_result",
SseEvent::Error { .. } => "error",
SseEvent::Complete => "complete",
}
}
pub fn data_json(&self) -> String {
match self {
SseEvent::Status { msg } => serde_json::json!({ "msg": msg }).to_string(),
SseEvent::ToolCall { name, args } => {
serde_json::json!({ "name": name, "args": args }).to_string()
}
SseEvent::ToolResult { preview, is_error } => {
serde_json::json!({ "preview": preview, "is_error": is_error }).to_string()
}
SseEvent::Error { msg } => serde_json::json!({ "msg": msg }).to_string(),
SseEvent::Complete => "{}".to_string(),
}
}
}
pub struct HttpIO {
tx: mpsc::Sender<SseEvent>,
}
impl HttpIO {
pub fn new() -> (Self, mpsc::Receiver<SseEvent>) {
let (tx, rx) = mpsc::channel(64);
(HttpIO { tx }, rx)
}
async fn send(&self, event: SseEvent) {
let _ = self.tx.send(event).await;
}
}
#[async_trait]
impl AgentIO for HttpIO {
async fn show_status(&self, msg: &str) -> Result<()> {
self.send(SseEvent::Status {
msg: msg.to_string(),
})
.await;
Ok(())
}
async fn show_tool_call(&self, tool_name: &str, args_preview: &str) -> Result<()> {
self.send(SseEvent::ToolCall {
name: tool_name.to_string(),
args: args_preview.to_string(),
})
.await;
Ok(())
}
async fn show_tool_result(&self, preview: &str, is_error: bool) -> Result<()> {
self.send(SseEvent::ToolResult {
preview: preview.to_string(),
is_error,
})
.await;
Ok(())
}
async fn write_error(&self, msg: &str) -> Result<()> {
self.send(SseEvent::Error {
msg: msg.to_string(),
})
.await;
Ok(())
}
async fn confirm_destructive(&self, tool_name: &str, args_preview: &str) -> Result<bool> {
self.send(SseEvent::Status {
msg: format!(
"⚠ auto-approving destructive call: {} ({})",
tool_name, args_preview
),
})
.await;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::AgentIO;
use std::sync::Arc;
fn make_io() -> (Arc<dyn AgentIO>, mpsc::Receiver<SseEvent>) {
let (io, rx) = HttpIO::new();
(Arc::new(io), rx)
}
#[test]
fn test_sse_event_names() {
assert_eq!(SseEvent::Status { msg: "x".into() }.event_name(), "status");
assert_eq!(
SseEvent::ToolCall {
name: "bash".into(),
args: "ls".into()
}
.event_name(),
"tool_call"
);
assert_eq!(
SseEvent::ToolResult {
preview: "ok".into(),
is_error: false
}
.event_name(),
"tool_result"
);
assert_eq!(SseEvent::Error { msg: "oops".into() }.event_name(), "error");
assert_eq!(SseEvent::Complete.event_name(), "complete");
}
#[test]
fn test_sse_event_data_json_status() {
let ev = SseEvent::Status {
msg: "hello".into(),
};
let json: serde_json::Value = serde_json::from_str(&ev.data_json()).unwrap();
assert_eq!(json["msg"], "hello");
}
#[test]
fn test_sse_event_data_json_tool_call() {
let ev = SseEvent::ToolCall {
name: "bash".into(),
args: "ls".into(),
};
let json: serde_json::Value = serde_json::from_str(&ev.data_json()).unwrap();
assert_eq!(json["name"], "bash");
assert_eq!(json["args"], "ls");
}
#[test]
fn test_sse_event_data_json_tool_result() {
let ev = SseEvent::ToolResult {
preview: "ok".into(),
is_error: true,
};
let json: serde_json::Value = serde_json::from_str(&ev.data_json()).unwrap();
assert_eq!(json["preview"], "ok");
assert_eq!(json["is_error"], true);
}
#[test]
fn test_sse_event_data_json_complete() {
let ev = SseEvent::Complete;
let json: serde_json::Value = serde_json::from_str(&ev.data_json()).unwrap();
assert!(json.is_object());
}
#[tokio::test]
async fn test_show_status_sends_event() {
let (io, mut rx) = make_io();
io.show_status("starting…").await.unwrap();
let ev = rx.recv().await.expect("expected event");
match ev {
SseEvent::Status { msg } => assert_eq!(msg, "starting…"),
other => panic!("unexpected: {:?}", other),
}
}
#[tokio::test]
async fn test_show_tool_call_sends_event() {
let (io, mut rx) = make_io();
io.show_tool_call("bash", "cargo test").await.unwrap();
let ev = rx.recv().await.unwrap();
match ev {
SseEvent::ToolCall { name, args } => {
assert_eq!(name, "bash");
assert_eq!(args, "cargo test");
}
other => panic!("unexpected: {:?}", other),
}
}
#[tokio::test]
async fn test_show_tool_result_sends_event() {
let (io, mut rx) = make_io();
io.show_tool_result("12 passed", false).await.unwrap();
let ev = rx.recv().await.unwrap();
match ev {
SseEvent::ToolResult { preview, is_error } => {
assert_eq!(preview, "12 passed");
assert!(!is_error);
}
other => panic!("unexpected: {:?}", other),
}
}
#[tokio::test]
async fn test_write_error_sends_event() {
let (io, mut rx) = make_io();
io.write_error("something failed").await.unwrap();
let ev = rx.recv().await.unwrap();
match ev {
SseEvent::Error { msg } => assert_eq!(msg, "something failed"),
other => panic!("unexpected: {:?}", other),
}
}
#[tokio::test]
async fn test_confirm_destructive_auto_approves() {
let (io, mut rx) = make_io();
let approved = io.confirm_destructive("bash", "rm -rf /").await.unwrap();
assert!(approved, "HTTP mode should auto-approve destructive calls");
let ev = rx.recv().await.unwrap();
assert!(matches!(ev, SseEvent::Status { .. }));
}
#[tokio::test]
async fn test_channel_closes_when_io_dropped() {
let (io, mut rx) = HttpIO::new();
drop(io);
assert!(
rx.recv().await.is_none(),
"channel should be closed after HttpIO dropped"
);
}
}