use std::time::Instant;
use zeph_agent_tools::channel::{AgentChannel, ChannelSinkError, ToolEventOutput, ToolEventStart};
use zeph_agent_tools::sealed::Sealed;
use crate::channel::{Channel, StopHint, ToolOutputEvent, ToolStartEvent};
#[allow(dead_code)]
pub(crate) struct AgentChannelView<'a, C: Channel> {
channel: &'a mut C,
}
impl<'a, C: Channel> AgentChannelView<'a, C> {
#[allow(dead_code)]
pub(crate) fn new(channel: &'a mut C) -> Self {
Self { channel }
}
}
impl<C: Channel> Sealed for AgentChannelView<'_, C> {}
impl<C: Channel + Send> AgentChannel for AgentChannelView<'_, C> {
async fn send(&mut self, text: &str) -> Result<(), ChannelSinkError> {
self.channel
.send(text)
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
async fn send_status(&mut self, text: &str) -> Result<(), ChannelSinkError> {
self.channel
.send_status(text)
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
async fn send_typing(&mut self) -> Result<(), ChannelSinkError> {
self.channel
.send_typing()
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
async fn flush_chunks(&mut self) -> Result<(), ChannelSinkError> {
self.channel
.flush_chunks()
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
async fn confirm(&mut self, prompt: &str) -> Result<bool, ChannelSinkError> {
self.channel
.confirm(prompt)
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
async fn send_stop_hint(&mut self, reason: &str) -> Result<(), ChannelSinkError> {
let hint = match reason {
"max_tokens" => StopHint::MaxTokens,
"max_turn_requests" => StopHint::MaxTurnRequests,
other => {
tracing::warn!(
reason = other,
"AgentChannelView: unknown stop reason, ignoring"
);
return Ok(());
}
};
self.channel
.send_stop_hint(hint)
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
async fn send_tool_start(&mut self, event: ToolEventStart<'_>) -> Result<(), ChannelSinkError> {
let canonical = ToolStartEvent {
tool_name: zeph_common::ToolName::new(event.tool_name),
tool_call_id: event.tool_use_id.to_owned(),
params: event
.args_summary
.map(|s| serde_json::Value::String(s.to_owned())),
parent_tool_use_id: event.parent_id.map(str::to_owned),
started_at: Instant::now(),
speculative: false,
sandbox_profile: None,
};
self.channel
.send_tool_start(canonical)
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
async fn send_tool_output(
&mut self,
event: ToolEventOutput<'_>,
) -> Result<(), ChannelSinkError> {
let canonical = ToolOutputEvent {
tool_name: zeph_common::ToolName::new(event.tool_name),
display: event.body.to_owned(),
diff: None,
filter_stats: None,
kept_lines: None,
locations: None,
tool_call_id: event.tool_use_id.to_owned(),
terminal_id: None,
is_error: event.is_error,
parent_tool_use_id: None,
raw_response: None,
started_at: None,
};
self.channel
.send_tool_output(canonical)
.await
.map_err(|e| ChannelSinkError::new(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel::{ChannelMessage, LoopbackChannel, LoopbackEvent};
#[tokio::test]
async fn agent_channel_view_forwards_send() {
let (mut ch, mut handle) = LoopbackChannel::pair(8);
let mut view = AgentChannelView::new(&mut ch);
view.send("hi").await.unwrap();
let event = handle.output_rx.recv().await.unwrap();
assert!(matches!(event, LoopbackEvent::FullMessage(m) if m == "hi"));
}
#[tokio::test]
async fn agent_channel_view_forwards_send_status() {
let (mut ch, mut handle) = LoopbackChannel::pair(8);
let mut view = AgentChannelView::new(&mut ch);
view.send_status("working...").await.unwrap();
let event = handle.output_rx.recv().await.unwrap();
assert!(matches!(event, LoopbackEvent::Status(s) if s == "working..."));
}
#[tokio::test]
async fn agent_channel_view_forwards_flush_chunks() {
let (mut ch, mut handle) = LoopbackChannel::pair(8);
let mut view = AgentChannelView::new(&mut ch);
view.flush_chunks().await.unwrap();
let event = handle.output_rx.recv().await.unwrap();
assert!(matches!(event, LoopbackEvent::Flush));
}
#[tokio::test]
async fn agent_channel_view_confirm_auto_approves() {
let (mut ch, _handle) = LoopbackChannel::pair(8);
let mut view = AgentChannelView::new(&mut ch);
let result = view.confirm("proceed?").await.unwrap();
assert!(result);
}
#[tokio::test]
async fn agent_channel_view_send_stop_hint_max_tokens() {
let (mut ch, mut handle) = LoopbackChannel::pair(8);
let mut view = AgentChannelView::new(&mut ch);
view.send_stop_hint("max_tokens").await.unwrap();
let event = handle.output_rx.recv().await.unwrap();
assert!(matches!(event, LoopbackEvent::Stop(StopHint::MaxTokens)));
}
#[tokio::test]
async fn agent_channel_view_send_stop_hint_unknown_is_noop() {
let (mut ch, _handle) = LoopbackChannel::pair(8);
let mut view = AgentChannelView::new(&mut ch);
view.send_stop_hint("unknown_reason").await.unwrap();
}
#[tokio::test]
async fn agent_channel_view_forwards_tool_output() {
use zeph_agent_tools::channel::ToolEventOutput;
let (mut ch, mut handle) = LoopbackChannel::pair(8);
let mut view = AgentChannelView::new(&mut ch);
let event = ToolEventOutput {
tool_name: "bash",
tool_use_id: "tc-001",
body: "exit 0",
is_error: false,
streamed: false,
};
view.send_tool_output(event).await.unwrap();
let ev = handle.output_rx.recv().await.unwrap();
match ev {
LoopbackEvent::ToolOutput(data) => {
assert_eq!(data.tool_name.as_str(), "bash");
assert_eq!(data.display, "exit 0");
assert!(!data.is_error);
}
_ => panic!("expected ToolOutput event"),
}
}
#[test]
fn agent_channel_view_does_not_move_channel() {
let (mut ch, _handle) = LoopbackChannel::pair(8);
{
let _view = AgentChannelView::new(&mut ch);
}
drop(ch);
}
#[test]
fn channel_message_roundtrip() {
let msg = ChannelMessage {
text: "test".to_owned(),
attachments: vec![],
};
assert_eq!(msg.text, "test");
}
}