#![cfg(target_arch = "wasm32")]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use wasm_bindgen_test::wasm_bindgen_test;
use yoagent::context::ExecutionLimits;
use yoagent::provider::mock::{MockResponse, MockToolCall};
use yoagent::provider::{
MockProvider, ModelConfig, ProviderError, StreamConfig, StreamEvent, StreamProvider,
};
use yoagent::retry::RetryConfig;
use yoagent::rt;
use yoagent::*;
#[wasm_bindgen_test]
async fn sleep_waits_on_the_host_timer() {
let start = rt::Instant::now();
rt::sleep(Duration::from_millis(30)).await;
assert!(
start.elapsed() >= Duration::from_millis(20),
"slept {:?}",
start.elapsed()
);
}
#[wasm_bindgen_test]
async fn timeout_returns_the_value_or_elapsed() {
let fast = rt::timeout(Duration::from_secs(5), async { 7 }).await;
assert_eq!(fast.ok(), Some(7));
let waited = rt::timeout(Duration::from_secs(1), async {
rt::sleep(Duration::from_millis(5)).await;
8
})
.await;
assert_eq!(waited.ok(), Some(8), "the timer fired before its deadline");
let start = rt::Instant::now();
let slow = rt::timeout(Duration::from_millis(30), futures::future::pending::<()>()).await;
assert!(slow.is_err(), "a pending future must time out");
assert!(
start.elapsed() >= Duration::from_millis(20),
"timed out after {:?}",
start.elapsed()
);
}
#[wasm_bindgen_test]
async fn spawn_runs_on_the_host_executor_and_returns_its_value() {
let handle = rt::spawn(async {
rt::sleep(Duration::from_millis(1)).await;
"done"
});
assert_eq!(handle.await.unwrap(), "done");
}
#[wasm_bindgen_test]
async fn an_aborted_task_reports_cancellation() {
let handle = rt::spawn(futures::future::pending::<()>());
handle.abort();
let err = handle.await.expect_err("an aborted task yields no value");
assert!(err.is_cancelled());
}
#[wasm_bindgen_test]
async fn instant_reads_the_host_clock_and_advances() {
let a = rt::Instant::now();
rt::sleep(Duration::from_millis(5)).await;
assert!(rt::Instant::now() > a);
}
struct Echo {
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait(?Send)]
impl AgentTool for Echo {
fn name(&self) -> &str {
"echo"
}
fn label(&self) -> &str {
"Echo"
}
fn description(&self) -> &str {
"Echo the input"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}}})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: ToolContext,
) -> Result<ToolResult, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
let text = params["text"].as_str().unwrap_or_default().to_string();
Ok(ToolResult {
content: vec![Content::Text { text }],
details: serde_json::Value::Null,
})
}
}
async fn drain(mut rx: mpsc::UnboundedReceiver<AgentEvent>) -> Vec<AgentEvent> {
let mut events = Vec::new();
while let Some(e) = rx.recv().await {
events.push(e);
}
events
}
fn final_text(events: &[AgentEvent]) -> String {
events
.iter()
.rev()
.find_map(|e| match e {
AgentEvent::MessageEnd {
message: AgentMessage::Llm(Message::Assistant { content, .. }),
} => content.iter().find_map(|c| match c {
Content::Text { text } if !text.is_empty() => Some(text.clone()),
_ => None,
}),
_ => None,
})
.unwrap_or_default()
}
#[wasm_bindgen_test]
async fn an_agent_runs_a_tool_turn_and_answers() {
let calls = Arc::new(AtomicUsize::new(0));
let provider = MockProvider::new(vec![
MockResponse::ToolCalls(vec![MockToolCall {
name: "echo".into(),
arguments: serde_json::json!({"text": "from the tool"}),
provider_metadata: None,
}]),
MockResponse::Text("the tool said: from the tool".into()),
]);
let mut agent = Agent::from_provider(provider, ModelConfig::mock())
.with_tools(vec![Box::new(Echo {
calls: calls.clone(),
})])
.with_execution_limits(
ExecutionLimits::default()
.with_max_turns(4)
.with_max_duration(Duration::from_secs(30)),
);
let events = drain(agent.prompt("use the tool").await).await;
agent.finish().await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(events.iter().any(|e| matches!(
e,
AgentEvent::ToolExecutionEnd {
is_error: false,
..
}
)));
assert_eq!(final_text(&events), "the tool said: from the tool");
assert!(matches!(events.last(), Some(AgentEvent::AgentEnd { .. })));
let stamped = agent.messages().iter().any(
|m| matches!(m, AgentMessage::Llm(Message::Assistant { timestamp, .. }) if *timestamp > 0),
);
assert!(stamped, "assistant messages carry a wall-clock timestamp");
}
struct FlakyOnce {
attempts: AtomicUsize,
inner: MockProvider,
}
#[async_trait::async_trait(?Send)]
impl StreamProvider for FlakyOnce {
async fn stream(
&self,
config: StreamConfig,
tx: mpsc::UnboundedSender<StreamEvent>,
cancel: CancellationToken,
) -> Result<Message, ProviderError> {
if self.attempts.fetch_add(1, Ordering::SeqCst) == 0 {
let _ = tx.send(StreamEvent::Start);
let _ = tx.send(StreamEvent::TextDelta {
content_index: 0,
delta: "partial".into(),
});
return Err(ProviderError::Network("stream cut".into()));
}
self.inner.stream(config, tx, cancel).await
}
}
#[wasm_bindgen_test]
async fn a_failed_attempt_is_retried_on_the_host_timer() {
let provider = FlakyOnce {
attempts: AtomicUsize::new(0),
inner: MockProvider::text("recovered"),
};
let mut agent =
Agent::from_provider(provider, ModelConfig::mock()).with_retry_config(RetryConfig {
max_retries: 2,
initial_delay_ms: 20,
backoff_multiplier: 1.0,
max_delay_ms: 20,
});
let started = rt::Instant::now();
let events = drain(agent.prompt("hi").await).await;
agent.finish().await;
let retries: Vec<_> = events
.iter()
.filter_map(|e| match e {
AgentEvent::ProviderRetry {
attempt,
max_attempts,
..
} => Some((*attempt, *max_attempts)),
_ => None,
})
.collect();
assert_eq!(retries, [(1, 3)]);
assert_eq!(final_text(&events), "recovered");
assert!(
started.elapsed() >= Duration::from_millis(10),
"{:?}",
started.elapsed()
);
}