#![cfg(feature = "testkit")]
mod common;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use serde_json::json;
fn test_state() -> std::sync::Arc<std::sync::RwLock<swink_agent::SessionState>> {
std::sync::Arc::new(std::sync::RwLock::new(swink_agent::SessionState::new()))
}
use swink_agent::{
AgentMessage, AgentOptions, AgentTool, AgentToolResult, AssistantMessageEvent, ContentBlock,
LlmMessage, StopReason, StreamFn, SubAgent, Usage,
};
use common::{MockStreamFn, default_model, text_only_events};
#[tokio::test]
async fn sub_agent_runs_and_returns_text() {
let stream_fn: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![text_only_events(
"sub-agent says hello",
)]));
let sfn = stream_fn.clone();
let sub =
SubAgent::new("researcher", "Researcher", "A research sub-agent").with_options(move || {
AgentOptions::new_simple("You are a researcher.", default_model(), Arc::clone(&sfn))
});
let params = serde_json::json!({ "prompt": "what is rust?" });
let ct = CancellationToken::new();
let result = sub
.execute("call-1", params, ct, None, test_state(), None)
.await;
assert!(!result.is_error);
let text = ContentBlock::extract_text(&result.content);
assert!(text.contains("sub-agent says hello"));
}
#[tokio::test]
async fn sub_agent_error_maps_to_tool_error() {
let error_events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::TextStart { content_index: 0 },
AssistantMessageEvent::TextDelta {
content_index: 0,
delta: "partial".into(),
},
AssistantMessageEvent::TextEnd { content_index: 0 },
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "model exploded".into(),
error_kind: None,
usage: Some(Usage::default()),
},
];
let stream_fn: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![error_events]));
let sfn = stream_fn.clone();
let sub = SubAgent::new("broken", "Broken", "Always fails")
.with_options(move || AgentOptions::new_simple("fail", default_model(), Arc::clone(&sfn)));
let params = serde_json::json!({ "prompt": "do something" });
let ct = CancellationToken::new();
let result = sub
.execute("call-2", params, ct, None, test_state(), None)
.await;
let text = ContentBlock::extract_text(&result.content);
assert!(!text.is_empty() || result.is_error);
}
#[tokio::test]
async fn sub_agent_cancellation() {
let stream_fn: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![text_only_events(
"this should not complete",
)]));
let sfn = stream_fn.clone();
let sub = SubAgent::new("slow", "Slow", "Gets cancelled").with_options(move || {
AgentOptions::new_simple("slow agent", default_model(), Arc::clone(&sfn))
});
let params = serde_json::json!({ "prompt": "go" });
let ct = CancellationToken::new();
ct.cancel();
let result = sub
.execute("call-3", params, ct, None, test_state(), None)
.await;
assert!(result.is_error);
let text = ContentBlock::extract_text(&result.content);
assert!(text.contains("cancelled") || text.contains("cancel"));
}
#[tokio::test]
async fn sub_agent_without_options_returns_tool_error() {
let sub = SubAgent::new("unset", "Unset", "No options configured");
let result = sub
.execute(
"call-unset",
json!({ "prompt": "go" }),
CancellationToken::new(),
None,
test_state(),
None,
)
.await;
assert!(result.is_error);
let text = ContentBlock::extract_text(&result.content);
assert!(text.contains("with_options()") || text.contains("simple()"));
}
#[tokio::test]
async fn sub_agent_shares_stream_fn() {
let stream_fn: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![text_only_events(
"shared stream works",
)]));
let sfn1 = Arc::clone(&stream_fn);
let sfn2 = Arc::clone(&stream_fn);
assert!(Arc::ptr_eq(&sfn1, &sfn2));
let sub = SubAgent::new("shared", "Shared", "Uses shared stream").with_options(move || {
AgentOptions::new_simple("shared", default_model(), Arc::clone(&sfn1))
});
assert_eq!(sub.name(), "shared");
assert_eq!(sub.label(), "Shared");
assert_eq!(sub.description(), "Uses shared stream");
}
#[tokio::test]
async fn default_map_result_with_error_and_no_message() {
let error_events = vec![
AssistantMessageEvent::Start,
AssistantMessageEvent::Error {
stop_reason: StopReason::Error,
error_message: "boom".into(),
error_kind: None,
usage: None,
},
];
let stream_fn2: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![error_events]));
let sfn2 = Arc::clone(&stream_fn2);
let sub2 = SubAgent::new("err", "Err", "errors")
.with_options(move || AgentOptions::new_simple("sys", default_model(), Arc::clone(&sfn2)));
let ct = CancellationToken::new();
let result = sub2
.execute(
"c1",
json!({"prompt": "go"}),
ct,
None,
std::sync::Arc::new(std::sync::RwLock::new(swink_agent::SessionState::new())),
None,
)
.await;
assert!(result.is_error);
let text = ContentBlock::extract_text(&result.content);
assert!(!text.is_empty());
}
#[tokio::test]
async fn default_map_result_with_no_assistant_messages() {
let called = Arc::new(std::sync::Mutex::new(false));
let called_clone = Arc::clone(&called);
let stream_fn: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![text_only_events("hi")]));
let sfn = Arc::clone(&stream_fn);
let sub = SubAgent::new("t", "T", "test")
.with_options(move || AgentOptions::new_simple("sys", default_model(), Arc::clone(&sfn)))
.with_map_result(move |result| {
*called_clone.lock().unwrap() = true;
let has_assistant = result
.messages
.iter()
.any(|m| matches!(m, AgentMessage::Llm(LlmMessage::Assistant(_))));
if has_assistant {
AgentToolResult::text("found assistant")
} else {
AgentToolResult::text("sub-agent produced no text output")
}
});
let ct = CancellationToken::new();
let result = sub
.execute(
"c1",
json!({"prompt": "hello"}),
ct,
None,
std::sync::Arc::new(std::sync::RwLock::new(swink_agent::SessionState::new())),
None,
)
.await;
assert!(*called.lock().unwrap());
let text = ContentBlock::extract_text(&result.content);
assert!(!text.is_empty());
}
#[tokio::test]
async fn custom_map_result() {
let stream_fn: Arc<dyn StreamFn> =
Arc::new(MockStreamFn::new(vec![text_only_events("original output")]));
let sfn = Arc::clone(&stream_fn);
let sub = SubAgent::new("custom", "Custom", "custom mapper")
.with_options(move || AgentOptions::new_simple("sys", default_model(), Arc::clone(&sfn)))
.with_map_result(|_result| AgentToolResult::text("custom mapped"));
let ct = CancellationToken::new();
let result = sub
.execute(
"c1",
json!({"prompt": "go"}),
ct,
None,
std::sync::Arc::new(std::sync::RwLock::new(swink_agent::SessionState::new())),
None,
)
.await;
assert!(!result.is_error);
let text = ContentBlock::extract_text(&result.content);
assert_eq!(text, "custom mapped");
}
#[test]
fn with_custom_schema() {
let custom_schema = json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"max_results": { "type": "integer" }
},
"required": ["query"]
});
let sub = SubAgent::new("s", "S", "schema test").with_schema(custom_schema.clone());
assert_eq!(sub.parameters_schema(), &custom_schema);
}
#[tokio::test]
async fn execute_with_empty_prompt() {
let stream_fn: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![text_only_events(
"empty prompt response",
)]));
let sfn = Arc::clone(&stream_fn);
let sub = SubAgent::new("ep", "EP", "empty prompt")
.with_options(move || AgentOptions::new_simple("sys", default_model(), Arc::clone(&sfn)));
let ct = CancellationToken::new();
let result = sub
.execute(
"c1",
json!({"prompt": ""}),
ct,
None,
std::sync::Arc::new(std::sync::RwLock::new(swink_agent::SessionState::new())),
None,
)
.await;
let text = ContentBlock::extract_text(&result.content);
assert!(!text.is_empty() || result.is_error);
}
#[tokio::test]
async fn execute_with_missing_prompt_param() {
let stream_fn: Arc<dyn StreamFn> = Arc::new(MockStreamFn::new(vec![text_only_events(
"no prompt response",
)]));
let sfn = Arc::clone(&stream_fn);
let sub = SubAgent::new("np", "NP", "no prompt")
.with_options(move || AgentOptions::new_simple("sys", default_model(), Arc::clone(&sfn)));
let ct = CancellationToken::new();
let result = sub
.execute(
"c1",
json!({"other": "value"}),
ct,
None,
std::sync::Arc::new(std::sync::RwLock::new(swink_agent::SessionState::new())),
None,
)
.await;
let text = ContentBlock::extract_text(&result.content);
assert!(!text.is_empty() || result.is_error);
}