#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use async_trait::async_trait;
use futures::stream;
use meerkat::{
AgentBuilder, AgentFactory, AgentToolDispatcher, LlmDoneOutcome, LlmEvent, LlmRequest,
OutputSchema, ToolDef, ToolError,
};
use meerkat_client::LlmClient;
use meerkat_core::ToolCallView;
use meerkat_core::ToolDispatchOutcome;
use serde::Deserialize;
use serde_json::{Value, json};
#[path = "support/test_session_store.rs"]
mod test_session_store;
use test_session_store::TestSessionStore;
struct MockLlmClientWithStructuredOutput {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl LlmClient for MockLlmClientWithStructuredOutput {
fn project_replay_messages(
&self,
messages: &[meerkat_core::Message],
) -> Result<Vec<meerkat_core::Message>, meerkat_client::LlmError> {
Ok(messages.to_vec())
}
fn stream<'a>(
&'a self,
_request: &'a LlmRequest,
) -> std::pin::Pin<
Box<dyn futures::Stream<Item = Result<LlmEvent, meerkat_client::LlmError>> + Send + 'a>,
> {
let call_index = self.calls.fetch_add(1, Ordering::SeqCst);
if call_index == 0 {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: "Let me provide the person info.".to_string(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat::StopReason::EndTurn,
},
}),
]))
} else {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: r#"{"name": "Alice", "age": 30}"#.to_string(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat::StopReason::EndTurn,
},
}),
]))
}
}
fn provider(&self) -> &'static str {
"mock"
}
async fn health_check(&self) -> Result<(), meerkat_client::LlmError> {
Ok(())
}
}
struct MockLlmClientWithRetry {
calls: Arc<AtomicUsize>,
}
struct MockLlmClientWithNamedWrapper {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl LlmClient for MockLlmClientWithNamedWrapper {
fn project_replay_messages(
&self,
messages: &[meerkat_core::Message],
) -> Result<Vec<meerkat_core::Message>, meerkat_client::LlmError> {
Ok(messages.to_vec())
}
fn stream<'a>(
&'a self,
_request: &'a LlmRequest,
) -> std::pin::Pin<
Box<dyn futures::Stream<Item = Result<LlmEvent, meerkat_client::LlmError>> + Send + 'a>,
> {
let call_index = self.calls.fetch_add(1, Ordering::SeqCst);
if call_index == 0 {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: "Working...".to_string(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat::StopReason::EndTurn,
},
}),
]))
} else {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: r#"{"advisor":{"name":"Eve","age":34}}"#.to_string(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat::StopReason::EndTurn,
},
}),
]))
}
}
fn provider(&self) -> &'static str {
"mock"
}
async fn health_check(&self) -> Result<(), meerkat_client::LlmError> {
Ok(())
}
}
#[async_trait]
impl LlmClient for MockLlmClientWithRetry {
fn project_replay_messages(
&self,
messages: &[meerkat_core::Message],
) -> Result<Vec<meerkat_core::Message>, meerkat_client::LlmError> {
Ok(messages.to_vec())
}
fn stream<'a>(
&'a self,
_request: &'a LlmRequest,
) -> std::pin::Pin<
Box<dyn futures::Stream<Item = Result<LlmEvent, meerkat_client::LlmError>> + Send + 'a>,
> {
let call_index = self.calls.fetch_add(1, Ordering::SeqCst);
match call_index {
0 => {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: "Processing...".to_string(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat::StopReason::EndTurn,
},
}),
]))
}
1 => {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: r#"{"name": "Bob"}"#.to_string(), meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat::StopReason::EndTurn,
},
}),
]))
}
_ => {
Box::pin(stream::iter(vec![
Ok(LlmEvent::TextDelta {
delta: r#"{"name": "Bob", "age": 25}"#.to_string(),
meta: None,
}),
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success {
stop_reason: meerkat::StopReason::EndTurn,
},
}),
]))
}
}
}
fn provider(&self) -> &'static str {
"mock"
}
async fn health_check(&self) -> Result<(), meerkat_client::LlmError> {
Ok(())
}
}
struct EmptyDispatcher;
#[async_trait]
impl AgentToolDispatcher for EmptyDispatcher {
fn tools(&self) -> Arc<[Arc<ToolDef>]> {
Arc::new([])
}
async fn dispatch(&self, call: ToolCallView<'_>) -> Result<ToolDispatchOutcome, ToolError> {
Err(ToolError::NotFound {
name: call.name.to_string(),
})
}
}
fn person_schema() -> Value {
json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"]
})
}
#[derive(Debug, Deserialize, PartialEq)]
struct Person {
name: String,
age: u32,
}
#[tokio::test]
async fn sdk_structured_output_extraction_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let calls = Arc::new(AtomicUsize::new(0));
let factory = AgentFactory::new(".rkat/sessions");
let llm_client = Arc::new(MockLlmClientWithStructuredOutput {
calls: calls.clone(),
});
let llm_adapter = Arc::new(
factory
.build_llm_adapter(llm_client, "claude-sonnet-4-6")
.await,
);
let store = Arc::new(TestSessionStore::new());
let store_adapter = Arc::new(factory.build_store_adapter(store).await);
let tools: Arc<dyn AgentToolDispatcher> = Arc::new(EmptyDispatcher);
let mut agent = AgentBuilder::new()
.model("claude-sonnet-4-6")
.max_tokens_per_turn(64)
.output_schema(OutputSchema::new(person_schema())?)
.build(llm_adapter, tools, store_adapter)
.await?;
let result = agent
.run("Tell me about a person.".to_string().into())
.await
.expect("agent run should succeed");
assert!(
result.structured_output.is_some(),
"structured_output should be present"
);
let person: Person =
serde_json::from_value(result.structured_output.unwrap()).expect("deserialize Person");
assert_eq!(person.name, "Alice");
assert_eq!(person.age, 30);
assert_eq!(calls.load(Ordering::SeqCst), 2);
Ok(())
}
#[tokio::test]
async fn sdk_structured_output_retry_on_invalid_json() -> Result<(), Box<dyn std::error::Error>> {
let calls = Arc::new(AtomicUsize::new(0));
let factory = AgentFactory::new(".rkat/sessions");
let llm_client = Arc::new(MockLlmClientWithRetry {
calls: calls.clone(),
});
let llm_adapter = Arc::new(
factory
.build_llm_adapter(llm_client, "claude-sonnet-4-6")
.await,
);
let store = Arc::new(TestSessionStore::new());
let store_adapter = Arc::new(factory.build_store_adapter(store).await);
let tools: Arc<dyn AgentToolDispatcher> = Arc::new(EmptyDispatcher);
let mut agent = AgentBuilder::new()
.model("claude-sonnet-4-6")
.max_tokens_per_turn(64)
.output_schema(OutputSchema::new(person_schema())?)
.structured_output_retries(2) .build(llm_adapter, tools, store_adapter)
.await?;
let result = agent
.run("Tell me about a person.".to_string().into())
.await
.expect("agent run should succeed after retry");
let person: Person =
serde_json::from_value(result.structured_output.unwrap()).expect("deserialize Person");
assert_eq!(person.name, "Bob");
assert_eq!(person.age, 25);
assert_eq!(calls.load(Ordering::SeqCst), 3);
Ok(())
}
#[tokio::test]
async fn sdk_no_structured_output_without_schema() {
let calls = Arc::new(AtomicUsize::new(0));
let factory = AgentFactory::new(".rkat/sessions");
let llm_client = Arc::new(MockLlmClientWithStructuredOutput { calls });
let llm_adapter = Arc::new(
factory
.build_llm_adapter(llm_client, "claude-sonnet-4-6")
.await,
);
let store = Arc::new(TestSessionStore::new());
let store_adapter = Arc::new(factory.build_store_adapter(store).await);
let tools: Arc<dyn AgentToolDispatcher> = Arc::new(EmptyDispatcher);
let mut agent = AgentBuilder::new()
.model("claude-sonnet-4-6")
.max_tokens_per_turn(64)
.build(llm_adapter, tools, store_adapter)
.await
.expect("public builder build");
let result = agent
.run("Hello".to_string().into())
.await
.expect("agent run should succeed");
assert!(
result.structured_output.is_none(),
"structured_output should be None without output_schema"
);
}
#[tokio::test]
async fn sdk_output_schema_builder_pattern() -> Result<(), Box<dyn std::error::Error>> {
let schema = person_schema();
let output_schema = OutputSchema::new(schema.clone())?
.with_name("person")
.strict();
assert!(output_schema.strict);
assert_eq!(output_schema.name, Some("person".to_string()));
assert_eq!(output_schema.schema.as_value(), &schema);
Ok(())
}
#[tokio::test]
async fn sdk_structured_output_unwraps_named_envelope() -> Result<(), Box<dyn std::error::Error>> {
let calls = Arc::new(AtomicUsize::new(0));
let factory = AgentFactory::new(".rkat/sessions");
let llm_client = Arc::new(MockLlmClientWithNamedWrapper {
calls: calls.clone(),
});
let llm_adapter = Arc::new(
factory
.build_llm_adapter(llm_client, "claude-sonnet-4-6")
.await,
);
let store = Arc::new(TestSessionStore::new());
let store_adapter = Arc::new(factory.build_store_adapter(store).await);
let tools: Arc<dyn AgentToolDispatcher> = Arc::new(EmptyDispatcher);
let mut agent = AgentBuilder::new()
.model("claude-sonnet-4-6")
.max_tokens_per_turn(64)
.output_schema(OutputSchema::new(person_schema())?.with_name("advisor"))
.build(llm_adapter, tools, store_adapter)
.await?;
let result = agent
.run("Tell me about a person.".to_string().into())
.await
.expect("agent run should succeed");
let person: Person =
serde_json::from_value(result.structured_output.expect("structured output present"))
.expect("deserialize Person");
assert_eq!(
person,
Person {
name: "Eve".to_string(),
age: 34
}
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
Ok(())
}