use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use skadoosh::config::Config;
use skadoosh::forward::{
forward_conversation, forward_tool_definition, ForwardConfig, ForwardTool, FORWARD_TOOL_NAME,
};
use skadoosh::llm::{Message, MessageContent};
use skadoosh::tools::ToolExecutor;
struct MockForward {
addr: SocketAddr,
captured: Arc<Mutex<Option<String>>>,
}
impl MockForward {
async fn serve(reply_body: &str) -> Self {
Self::serve_with(200, reply_body).await
}
async fn serve_with(status: u16, reply_body: &str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind mock forward server");
let addr = listener.local_addr().expect("local addr");
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let captured_clone = Arc::clone(&captured);
let reply = reply_body.to_string();
tokio::spawn(async move {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
let header_end = loop {
let n = match sock.read(&mut tmp).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
buf.extend_from_slice(&tmp[..n]);
if let Some(pos) = find(&buf, b"\r\n\r\n") {
break pos + 4;
}
if buf.len() > 64 * 1024 {
return;
}
};
let headers = String::from_utf8_lossy(&buf[..header_end]);
let mut content_len = 0usize;
for line in headers.lines() {
if let Some((k, v)) = line.split_once(':') {
if k.trim().eq_ignore_ascii_case("content-length") {
content_len = v.trim().parse().unwrap_or(0);
}
}
}
while buf.len() - header_end < content_len {
match sock.read(&mut tmp).await {
Ok(0) | Err(_) => return,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
}
}
*captured_clone.lock().unwrap() = Some(String::from_utf8_lossy(&buf).into_owned());
let head = format!(
"HTTP/1.1 {status} OK\r\ncontent-type: text/plain\r\n\
content-length: {}\r\nconnection: close\r\n\r\n",
reply.len()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(reply.as_bytes()).await;
let _ = sock.shutdown().await;
});
Self { addr, captured }
}
fn url(&self) -> String {
format!("http://{}", self.addr)
}
fn body(&self) -> String {
let raw = self.captured.lock().unwrap().clone().unwrap_or_default();
match find(raw.as_bytes(), b"\r\n\r\n") {
Some(pos) => raw[pos + 4..].to_string(),
None => raw,
}
}
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn user_msg(text: &str) -> Message {
Message {
role: "user".to_string(),
content: MessageContent::Text(text.to_string()),
tool_call_id: None,
tool_calls: None,
}
}
fn system_msg(text: &str) -> Message {
Message {
role: "system".to_string(),
content: MessageContent::Text(text.to_string()),
tool_call_id: None,
tool_calls: None,
}
}
#[tokio::test]
async fn forward_conversation_posts_context_and_relays_response() {
let server = MockForward::serve("The billing team says: your invoice is paid.").await;
let config = ForwardConfig {
endpoint: server.url(),
timeout_secs: 5,
};
let history = vec![
system_msg("You are a voice assistant."),
user_msg("What is the status of my invoice?"),
];
let reply = forward_conversation(
&config,
&history,
"What is the status of my invoice?",
"I cannot access billing records.",
"Ask for the invoice payment status.",
)
.await
.expect("forward_conversation should succeed");
assert_eq!(reply, "The billing team says: your invoice is paid.");
let body: serde_json::Value = serde_json::from_str(&server.body()).expect("body is JSON");
assert_eq!(body["reason"], "I cannot access billing records.");
assert_eq!(body["summary"], "Ask for the invoice payment status.");
assert_eq!(body["current_query"], "What is the status of my invoice?");
let hist = body["history"].as_array().expect("history is array");
assert_eq!(hist.len(), 2, "full history is forwarded");
assert_eq!(hist[0]["role"], "system");
assert_eq!(hist[1]["role"], "user");
assert_eq!(hist[1]["content"], "What is the status of my invoice?");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn forward_tool_executor_posts_and_returns_response() {
let server = MockForward::serve("transferred-agent-reply").await;
let tool = ForwardTool::new(ForwardConfig {
endpoint: server.url(),
timeout_secs: 5,
});
let args = r#"{"reason":"out of scope","summary":"handle the refund request"}"#;
let out = tool
.execute(FORWARD_TOOL_NAME, args)
.expect("execute should succeed");
assert_eq!(out, "transferred-agent-reply");
let body: serde_json::Value = serde_json::from_str(&server.body()).expect("body is JSON");
assert_eq!(body["reason"], "out of scope");
assert_eq!(body["summary"], "handle the refund request");
}
#[tokio::test]
async fn forward_conversation_surfaces_non_success_as_error() {
let server = MockForward::serve_with(503, "upstream down").await;
let config = ForwardConfig {
endpoint: server.url(),
timeout_secs: 5,
};
let err = forward_conversation(&config, &[], "q", "r", "s")
.await
.expect_err("non-2xx should error");
assert!(
err.to_string().contains("503"),
"error should mention status: {err}"
);
}
#[test]
fn forward_tool_definition_has_expected_shape() {
let tool = forward_tool_definition();
assert_eq!(tool.function.name, FORWARD_TOOL_NAME);
assert!(
tool.function
.description
.as_deref()
.unwrap_or("")
.contains("Forward this conversation"),
"description should describe forwarding: {:?}",
tool.function.description
);
let params = &tool.function.parameters;
assert_eq!(params["type"], "object");
assert_eq!(params["properties"]["reason"]["type"], "string");
assert_eq!(params["properties"]["summary"]["type"], "string");
assert_eq!(params["required"][0], "reason");
assert_eq!(params["required"][1], "summary");
}
#[test]
fn forward_url_flag_is_parsed() {
use clap::Parser;
let config = Config::try_parse_from(["skadoosh", "--forward-url", "http://example/forward"])
.expect("parses");
assert_eq!(
config.forward_url.as_deref(),
Some("http://example/forward")
);
let default = Config::try_parse_from(["skadoosh"]).expect("parses");
assert!(default.forward_url.is_none(), "default forward_url is None");
}
#[test]
fn default_config_has_no_forward_url() {
let default = Config::default();
assert!(default.forward_url.is_none());
}