#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use meerkat::*;
use schemars::JsonSchema;
#[cfg(feature = "mcp")]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
mod llm_normalization {
use super::*;
use futures::StreamExt;
fn first_env(vars: &[&str]) -> Option<String> {
for name in vars {
if let Ok(value) = std::env::var(name) {
return Some(value);
}
}
None
}
#[tokio::test]
#[ignore = "lane:e2e-live"]
async fn e2e_anthropic_normalizes_to_llm_event() {
let Some(api_key) = first_env(&["RKAT_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"]) else {
eprintln!("Skipping: missing ANTHROPIC_API_KEY (or RKAT_ANTHROPIC_API_KEY)");
return;
};
let client = AnthropicClient::new(api_key).unwrap();
let request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text(
"Say 'hello' and nothing else".to_string(),
))],
);
let mut stream = client.stream(&request);
let mut got_text_delta = false;
let mut got_done = false;
while let Some(event) = stream.next().await {
match event {
Ok(LlmEvent::TextDelta { delta, .. }) => {
let _ = delta;
got_text_delta = true;
}
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason },
}) => {
assert!(matches!(
stop_reason,
StopReason::EndTurn | StopReason::MaxTokens | StopReason::StopSequence
));
got_done = true;
}
Ok(LlmEvent::Done {
outcome: LlmDoneOutcome::Error { error },
}) => panic!("Unexpected error outcome: {error:?}"),
Ok(LlmEvent::ToolCallDelta { .. }) => {
}
Ok(LlmEvent::ToolCallComplete { .. }) => {
}
Ok(LlmEvent::UsageUpdate { usage }) => {
assert!(usage.input_tokens > 0 || usage.output_tokens > 0);
}
Ok(LlmEvent::ReasoningDelta { .. } | LlmEvent::ReasoningComplete { .. }) => {
}
Err(e) => panic!("Unexpected error: {e:?}"),
}
}
assert!(
got_text_delta,
"Should have received at least one TextDelta"
);
assert!(got_done, "Should have received Done event");
}
#[cfg(feature = "openai")]
#[tokio::test]
#[ignore = "lane:e2e-live"]
async fn e2e_openai_normalizes_to_llm_event() {
let Some(api_key) = first_env(&["RKAT_OPENAI_API_KEY", "OPENAI_API_KEY"]) else {
eprintln!("Skipping: missing OPENAI_API_KEY (or RKAT_OPENAI_API_KEY)");
return;
};
let client = OpenAiClient::new(api_key);
let request = LlmRequest::new(
"gpt-5.2",
vec![Message::User(UserMessage::text(
"Say 'hello' and nothing else".to_string(),
))],
);
let mut stream = client.stream(&request);
let mut got_text_delta = false;
let mut got_done = false;
while let Some(event) = stream.next().await {
match event {
Ok(LlmEvent::TextDelta { .. }) => got_text_delta = true,
Ok(LlmEvent::Done { .. }) => got_done = true,
Ok(_) => {}
Err(e) => panic!("Unexpected error: {e:?}"),
}
}
assert!(
got_text_delta,
"Should have received at least one TextDelta"
);
assert!(got_done, "Should have received Done event");
}
#[cfg(feature = "gemini")]
#[tokio::test]
#[ignore = "lane:e2e-live"]
async fn e2e_gemini_normalizes_to_llm_event() {
let Some(api_key) = first_env(&["RKAT_GEMINI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY"])
else {
eprintln!("Skipping: missing GOOGLE_API_KEY (or GEMINI_API_KEY/RKAT_GEMINI_API_KEY)");
return;
};
let client = GeminiClient::new(api_key);
let request = LlmRequest::new(
"gemini-2.0-flash",
vec![Message::User(UserMessage::text(
"Say 'hello' and nothing else".to_string(),
))],
);
let mut stream = client.stream(&request);
let mut got_text_delta = false;
let mut got_done = false;
while let Some(event) = stream.next().await {
match event {
Ok(LlmEvent::TextDelta { .. }) => got_text_delta = true,
Ok(LlmEvent::Done { .. }) => got_done = true,
Ok(_) => {}
Err(e) => panic!("Unexpected error: {e:?}"),
}
}
assert!(
got_text_delta,
"Should have received at least one TextDelta"
);
assert!(got_done, "Should have received Done event");
}
#[test]
fn test_provider_error_classification() {
let rate_limit = LlmError::RateLimited {
retry_after_ms: Some(30000),
};
assert!(rate_limit.is_retryable(), "Rate limit should be retryable");
let auth_error = LlmError::AuthenticationFailed {
message: "Invalid API key".to_string(),
};
assert!(
!auth_error.is_retryable(),
"Auth errors should not be retryable"
);
let overload = LlmError::ServerOverloaded;
assert!(
overload.is_retryable(),
"Server overload should be retryable"
);
let invalid = LlmError::InvalidRequest {
message: "Bad request".to_string(),
};
assert!(
!invalid.is_retryable(),
"Invalid request should not be retryable"
);
}
}
mod tool_dispatch {
use super::*;
#[derive(Debug, Clone, JsonSchema)]
#[allow(dead_code)]
struct ToolInput {
input: String,
}
#[test]
fn test_tool_discovery_validates_schema() {
let mut registry = ToolRegistry::new();
let valid_tool = ToolDef {
name: "test_tool".to_string(),
description: "A test tool".to_string(),
input_schema: meerkat_tools::schema_for::<ToolInput>(),
provenance: None,
};
registry.register(valid_tool);
assert!(
registry.get("test_tool").is_some(),
"Should find registered tool"
);
}
#[cfg(feature = "mcp")]
#[test]
fn test_tool_timeout_enforced() {
let registry = ToolRegistry::new();
let router: Arc<dyn AgentToolDispatcher> = Arc::new(McpRouter::new());
let timeout = Duration::from_secs(30);
let dispatcher = ToolDispatcher::new(registry, router).with_timeout(timeout);
assert!(std::mem::size_of_val(&dispatcher) > 0);
}
#[test]
fn test_tool_error_captured() {
let error = ToolError::execution_failed("Something went wrong with test_tool");
let error_str = format!("{error:?}");
assert!(error_str.contains("Something went wrong"));
assert!(error_str.contains("test_tool"));
let not_found = ToolError::not_found("missing_tool");
assert!(format!("{not_found:?}").contains("missing_tool"));
let timeout = ToolError::timeout("slow_tool", 5000);
assert!(format!("{timeout:?}").contains("slow_tool"));
let validation = ToolError::invalid_arguments("test_tool", "invalid params");
assert!(format!("{validation:?}").contains("invalid params"));
}
}
#[cfg(feature = "jsonl-store")]
mod session_persistence {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_checkpoint_atomic_write() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = JsonlStore::new(temp_dir.path().to_path_buf());
store.init().await.expect("Failed to init store");
let mut session = Session::new();
session.push(Message::User(UserMessage::text("Hello".to_string())));
session.push(Message::Assistant(AssistantMessage {
content: "Hi there!".to_string(),
tool_calls: vec![],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}));
session.push(Message::User(UserMessage::text("How are you?".to_string())));
let session_id = session.id().clone();
store.save(&session).await.expect("Save should succeed");
let loaded = store
.load(&session_id)
.await
.expect("Load should succeed")
.expect("Session should exist");
assert_eq!(loaded.messages().len(), 3);
assert!(matches!(loaded.messages()[0], Message::User(_)));
assert!(matches!(loaded.messages()[1], Message::Assistant(_)));
assert!(matches!(loaded.messages()[2], Message::User(_)));
}
#[tokio::test]
async fn test_resume_after_crash() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = JsonlStore::new(temp_dir.path().to_path_buf());
store.init().await.expect("Failed to init store");
let session_id = {
let mut session = Session::new();
session.push(Message::User(UserMessage::text("Before crash".to_string())));
session.push(Message::Assistant(AssistantMessage {
content: "Response before crash".to_string(),
tool_calls: vec![],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}));
let id = session.id().clone();
store.save(&session).await.expect("Save should succeed");
id
};
let store2 = JsonlStore::new(temp_dir.path().to_path_buf());
let resumed = store2
.load(&session_id)
.await
.expect("Resume should succeed")
.expect("Session should exist");
assert_eq!(resumed.messages().len(), 2);
assert_eq!(*resumed.id(), session_id);
}
#[tokio::test]
async fn test_session_roundtrip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let store = JsonlStore::new(temp_dir.path().to_path_buf());
store.init().await.expect("Failed to init store");
let mut session = Session::new();
session.push(Message::System(SystemMessage {
content: "You are helpful".to_string(),
}));
session.push(Message::User(UserMessage::text("Hello".to_string())));
session.push(Message::Assistant(AssistantMessage {
content: "Hi!".to_string(),
tool_calls: vec![],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}));
session.push(Message::User(UserMessage::text("Call a tool".to_string())));
session.push(Message::ToolResults {
results: vec![ToolResult::new(
"call_123".to_string(),
"Tool output".to_string(),
false,
)],
});
let original_id = session.id().clone();
let original_len = session.messages().len();
store.save(&session).await.expect("Save failed");
let loaded = store
.load(&original_id)
.await
.expect("Load failed")
.expect("Session should exist");
assert_eq!(*loaded.id(), original_id);
assert_eq!(loaded.messages().len(), original_len);
}
}
mod config_loading {
use super::*;
#[test]
fn test_config_precedence() {
let default_config = Config::default();
assert!(
default_config.agent.max_tokens_per_turn > 0,
"Default config should have positive max_tokens"
);
}
#[test]
fn test_config_type_coercion() {
let toml_str = r#"
max_retries = 5
initial_delay = "1s"
max_delay = "1m"
multiplier = 2.0
"#;
let retry_config: RetryConfig = toml::from_str(toml_str).expect("Should parse TOML");
assert_eq!(retry_config.max_retries, 5);
assert_eq!(
retry_config.initial_delay,
std::time::Duration::from_secs(1)
);
assert_eq!(retry_config.max_delay, std::time::Duration::from_secs(60));
let budget_toml = r"
max_tokens = 100000
max_tool_calls = 50
";
let budget: BudgetLimits = toml::from_str(budget_toml).expect("Should parse budget TOML");
assert_eq!(budget.max_tokens, Some(100000));
assert_eq!(budget.max_tool_calls, Some(50));
}
}
mod retry_policy {
use super::*;
#[test]
fn test_retryable_errors_retry() {
let policy = RetryPolicy::default();
assert!(
policy.max_retries > 0,
"Default policy should allow retries"
);
let rate_limit = LlmError::RateLimited {
retry_after_ms: None,
};
assert!(rate_limit.is_retryable(), "Rate limit should trigger retry");
assert!(policy.should_retry(0), "Should retry on first attempt");
assert!(policy.should_retry(1), "Should retry on second attempt");
assert!(
!policy.should_retry(policy.max_retries),
"Should not retry after max attempts"
);
}
#[test]
fn test_non_retryable_fail_fast() {
let auth_error = LlmError::AuthenticationFailed {
message: "Invalid key".to_string(),
};
assert!(!auth_error.is_retryable(), "Auth errors should never retry");
let invalid = LlmError::InvalidRequest {
message: "Bad params".to_string(),
};
assert!(
!invalid.is_retryable(),
"Invalid requests should never retry"
);
}
#[test]
fn test_exponential_backoff() {
let policy = RetryPolicy::default();
let delay_0 = policy.delay_for_attempt(0);
assert_eq!(
delay_0,
Duration::ZERO,
"First attempt should have no delay"
);
let delay_1 = policy.delay_for_attempt(1);
let delay_2 = policy.delay_for_attempt(2);
let delay_3 = policy.delay_for_attempt(3);
assert!(delay_1 > Duration::ZERO, "Second attempt should have delay");
assert!(delay_2 > delay_1 / 2, "Delays should generally increase");
assert!(delay_3 > delay_2 / 2, "Delays should continue increasing");
let delay_100 = policy.delay_for_attempt(100);
let max_with_jitter = policy.max_delay + policy.max_delay / 10;
assert!(delay_100 <= max_with_jitter, "Delay should be capped");
}
}
mod budget_enforcement {
use super::*;
#[test]
fn test_budget_token_limit_enforced() {
let budget = Budget::new(BudgetLimits {
max_tokens: Some(1000),
max_duration: None,
max_tool_calls: None,
});
assert!(budget.check().is_ok(), "Budget check should pass initially");
budget.record_tokens(500);
assert!(
budget.check().is_ok(),
"Budget check should pass within limit"
);
assert_eq!(budget.token_usage(), Some((500, 1000)));
budget.record_tokens(600);
assert!(
budget.check().is_err(),
"Budget check should fail over limit"
);
assert!(budget.is_exhausted(), "Budget should be exhausted");
}
#[test]
fn test_budget_tool_call_limit_enforced() {
let budget = Budget::new(BudgetLimits {
max_tokens: None,
max_duration: None,
max_tool_calls: Some(3),
});
assert!(budget.check().is_ok());
budget.record_tool_call();
budget.record_tool_call();
budget.record_tool_call();
assert!(budget.check().is_err(), "Should fail at limit");
}
#[test]
fn test_budget_unlimited() {
let budget = Budget::new(BudgetLimits {
max_tokens: None,
max_duration: None,
max_tool_calls: None,
});
budget.record_tokens(1_000_000);
budget.record_tokens(1_000_000);
assert!(
budget.check().is_ok(),
"Unlimited budget should always pass"
);
for _ in 0..100 {
budget.record_tool_call();
}
assert!(
budget.check().is_ok(),
"Unlimited budget should always pass"
);
}
}
mod operation_injection {
use super::*;
#[test]
fn test_results_injected_at_turn_boundary() {
let op_result = OperationResult {
id: OperationId::new(),
content: "Tool output".to_string(),
is_error: false,
duration_ms: 100,
tokens_used: 50,
};
let json = serde_json::to_string(&op_result).expect("Should serialize");
assert!(json.contains("Tool output"));
let parsed: OperationResult = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(parsed.content, "Tool output");
assert!(!parsed.is_error);
assert_eq!(parsed.duration_ms, 100);
assert_eq!(parsed.tokens_used, 50);
}
#[test]
fn test_artifact_ref_resolution() {
let session_id = SessionId::new();
let artifact = ArtifactRef {
id: "artifact_123".to_string(),
session_id,
size_bytes: 1024,
ttl_seconds: Some(3600),
version: 1,
};
assert_eq!(artifact.id, "artifact_123");
assert_eq!(artifact.version, 1);
assert_eq!(artifact.size_bytes, 1024);
assert_eq!(artifact.ttl_seconds, Some(3600));
let json = serde_json::to_string(&artifact).expect("Should serialize");
assert!(json.contains("artifact_123"));
let parsed: ArtifactRef = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(parsed.id, artifact.id);
assert_eq!(parsed.version, artifact.version);
}
#[test]
fn test_event_ordering_preserved() {
let op_id = OperationId::new();
let started = OpEvent::Started {
id: op_id.clone(),
kind: WorkKind::ToolCall,
};
let progress = OpEvent::Progress {
id: op_id.clone(),
message: "Working...".to_string(),
percent: Some(0.5),
};
let result_id = op_id;
let completed = OpEvent::Completed {
id: result_id.clone(),
result: OperationResult {
id: result_id,
content: "Done".to_string(),
is_error: false,
duration_ms: 100,
tokens_used: 0,
},
};
for event in [&started, &progress, &completed] {
let json = serde_json::to_string(event).expect("Should serialize");
assert!(!json.is_empty());
}
}
}
mod tool_access_policy {
use super::*;
#[test]
fn test_allow_list_structure() {
let policy =
ToolAccessPolicy::AllowList(vec!["safe_tool".to_string(), "another_safe".to_string()]);
let json = serde_json::to_value(&policy).expect("Should serialize");
assert_eq!(json["type"], "allow_list");
assert!(json["value"].is_array());
let parsed: ToolAccessPolicy = serde_json::from_value(json).expect("Should deserialize");
match parsed {
ToolAccessPolicy::AllowList(tools) => {
assert_eq!(tools.len(), 2);
assert!(tools.contains(&"safe_tool".to_string()));
assert!(tools.contains(&"another_safe".to_string()));
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_deny_list_structure() {
let policy = ToolAccessPolicy::DenyList(vec!["dangerous_tool".to_string()]);
let json = serde_json::to_value(&policy).expect("Should serialize");
assert_eq!(json["type"], "deny_list");
let parsed: ToolAccessPolicy = serde_json::from_value(json).expect("Should deserialize");
match parsed {
ToolAccessPolicy::DenyList(tools) => {
assert_eq!(tools.len(), 1);
assert!(tools.contains(&"dangerous_tool".to_string()));
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_inherit_policy() {
let policy = ToolAccessPolicy::Inherit;
let json = serde_json::to_value(&policy).expect("Should serialize");
assert_eq!(json["type"], "inherit");
let parsed: ToolAccessPolicy = serde_json::from_value(json).expect("Should deserialize");
assert!(matches!(parsed, ToolAccessPolicy::Inherit));
}
}
mod state_machine {
use super::*;
use meerkat::AgentError;
fn can_transition(from: &LoopState, next: &LoopState) -> bool {
use LoopState::{
CallingLlm, Cancelling, Completed, DrainingEvents, ErrorRecovery, WaitingForOps,
};
matches!(
(from, next),
(
CallingLlm,
WaitingForOps | DrainingEvents | Completed | ErrorRecovery | Cancelling
) | (WaitingForOps, DrainingEvents | Cancelling)
| (
DrainingEvents | ErrorRecovery,
CallingLlm | Completed | Cancelling
)
| (Cancelling, Completed)
)
}
fn transition(state: &mut LoopState, next: LoopState) -> Result<(), AgentError> {
if can_transition(state, &next) {
*state = next;
Ok(())
} else {
Err(AgentError::InvalidStateTransition {
from: format!("{state:?}"),
to: format!("{next:?}"),
})
}
}
#[test]
fn test_valid_state_transitions() {
let mut state = LoopState::CallingLlm;
assert!(transition(&mut state, LoopState::DrainingEvents).is_ok());
assert_eq!(state, LoopState::DrainingEvents);
assert!(transition(&mut state, LoopState::CallingLlm).is_ok());
assert_eq!(state, LoopState::CallingLlm);
assert!(transition(&mut state, LoopState::Completed).is_ok());
assert_eq!(state, LoopState::Completed);
assert!(state.is_terminal(), "Completed should be terminal");
}
#[test]
fn test_invalid_transitions_from_terminal() {
let mut state = LoopState::Completed;
assert!(
transition(&mut state, LoopState::CallingLlm).is_err(),
"Should not transition from terminal state"
);
}
#[test]
fn test_cancellation_path() {
let mut state = LoopState::CallingLlm;
assert!(transition(&mut state, LoopState::Cancelling).is_ok());
assert_eq!(state, LoopState::Cancelling);
assert!(transition(&mut state, LoopState::Completed).is_ok());
assert!(state.is_terminal(), "Completed should be terminal");
}
#[test]
fn test_error_recovery_path() {
let mut state = LoopState::CallingLlm;
assert!(transition(&mut state, LoopState::ErrorRecovery).is_ok());
assert!(transition(&mut state, LoopState::CallingLlm).is_ok());
transition(&mut state, LoopState::ErrorRecovery).ok();
assert!(transition(&mut state, LoopState::Completed).is_ok());
assert!(state.is_terminal());
}
#[test]
fn test_waiting_for_ops_path() {
let mut state = LoopState::CallingLlm;
assert!(transition(&mut state, LoopState::WaitingForOps).is_ok());
assert!(transition(&mut state, LoopState::DrainingEvents).is_ok());
assert!(transition(&mut state, LoopState::Completed).is_ok());
}
}
#[cfg(feature = "mcp")]
mod mcp_protocol {
use super::*;
#[test]
fn test_mcp_config_structure() {
let config = McpServerConfig::stdio(
"test-server",
"node",
vec!["test-server.js".to_string()],
HashMap::new(),
);
assert_eq!(config.name, "test-server");
match &config.transport {
meerkat_core::mcp_config::McpTransportConfig::Stdio(stdio) => {
assert_eq!(stdio.command, "node");
assert_eq!(stdio.args.len(), 1);
}
_ => panic!("Expected stdio transport"),
}
let json = serde_json::to_value(&config).expect("Should serialize");
assert_eq!(json["name"], "test-server");
assert_eq!(json["command"], "node");
}
#[tokio::test]
async fn test_mcp_router_creation() {
let router = McpRouter::new();
let tools = router.list_tools();
assert!(tools.is_empty(), "No tools without servers");
}
#[tokio::test]
#[ignore = "lane:e2e-system"]
async fn integration_real_mcp_tool_call_roundtrip() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let workspace_root = std::path::Path::new(&manifest_dir)
.parent()
.unwrap_or(std::path::Path::new("."));
let server_path = workspace_root.join("target/debug/mcp-test-server");
if !server_path.exists() {
eprintln!("Skipping: MCP test server not built (run cargo build -p mcp-test-server)");
return;
}
let config = McpServerConfig::stdio(
"test",
server_path.to_string_lossy().to_string(),
vec![],
HashMap::new(),
);
let connection = McpConnection::connect(&config)
.await
.expect("Should connect to test server");
let tools = connection
.list_tools("test-server")
.await
.expect("Should list tools");
assert!(!tools.is_empty(), "Test server should have tools");
let echo_tool = tools
.iter()
.find(|t| t.name == "echo")
.expect("Test server should have echo tool");
assert_eq!(echo_tool.name, "echo");
let blocks = connection
.call_tool("echo", &serde_json::json!({"message": "test"}))
.await
.expect("Tool call should succeed");
let result_text = meerkat_core::types::text_content(&blocks);
assert!(result_text.contains("test"), "Echo should return input");
connection.close().await.expect("Should close cleanly");
}
}
mod combined {
use super::*;
#[derive(Debug, Clone, JsonSchema)]
#[allow(dead_code)]
struct ReadFileArgs {
path: String,
}
#[derive(Debug, Clone, JsonSchema)]
#[allow(dead_code)]
struct WriteFileArgs {
path: String,
content: String,
}
#[test]
fn test_session_with_tool_results() {
let mut session = Session::new();
session.push(Message::User(UserMessage::text("Call a tool".to_string())));
let first_usage = Usage {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: None,
cache_read_tokens: None,
};
session.push(Message::Assistant(AssistantMessage {
content: "".to_string(),
tool_calls: vec![ToolCall::new(
"tc_1".to_string(),
"test_tool".to_string(),
serde_json::json!({"input": "test"}),
)],
stop_reason: StopReason::ToolUse,
usage: first_usage.clone(),
}));
session.record_usage(first_usage);
session.push(Message::ToolResults {
results: vec![ToolResult::new(
"tc_1".to_string(),
"Tool result".to_string(),
false,
)],
});
let second_usage = Usage {
input_tokens: 150,
output_tokens: 75,
cache_creation_tokens: None,
cache_read_tokens: None,
};
session.push(Message::Assistant(AssistantMessage {
content: "Based on the tool result...".to_string(),
tool_calls: vec![],
stop_reason: StopReason::EndTurn,
usage: second_usage.clone(),
}));
session.record_usage(second_usage);
assert_eq!(session.messages().len(), 4);
assert_eq!(session.tool_call_count(), 1);
assert_eq!(session.total_tokens(), 375); }
#[test]
fn test_budget_with_usage_recording() {
let budget = Budget::new(BudgetLimits::default().with_max_tokens(1000));
let usage = Usage {
input_tokens: 200,
output_tokens: 100,
cache_creation_tokens: None,
cache_read_tokens: None,
};
budget.record_usage(&usage);
assert_eq!(budget.token_usage(), Some((300, 1000)));
assert_eq!(budget.remaining_tokens(), Some(700));
}
#[test]
fn test_operation_spec_completeness() {
let spec = OperationSpec {
id: OperationId::new(),
kind: WorkKind::ToolCall,
result_shape: ResultShape::Single,
policy: OperationPolicy {
timeout_ms: Some(30000),
cancel_on_parent_cancel: true,
checkpoint_results: true,
},
budget_reservation: BudgetLimits::default().with_max_tokens(1000),
depth: 0,
depends_on: vec![],
context: Some(ContextStrategy::FullHistory),
tool_access: Some(ToolAccessPolicy::Inherit),
};
let json = serde_json::to_string(&spec).expect("Should serialize");
assert!(json.contains("tool_call"));
let parsed: OperationSpec = serde_json::from_str(&json).expect("Should deserialize");
assert_eq!(parsed.kind, spec.kind);
assert_eq!(parsed.result_shape, spec.result_shape);
}
#[test]
fn test_llm_request_with_tools() {
let tools = vec![
Arc::new(ToolDef {
name: "read_file".to_string(),
description: "Read a file".to_string(),
input_schema: meerkat_tools::schema_for::<ReadFileArgs>(),
provenance: None,
}),
Arc::new(ToolDef {
name: "write_file".to_string(),
description: "Write a file".to_string(),
input_schema: meerkat_tools::schema_for::<WriteFileArgs>(),
provenance: None,
}),
];
let request = LlmRequest::new(
"claude-opus-4-6",
vec![Message::User(UserMessage::text(
"Read the file".to_string(),
))],
)
.with_tools(tools)
.with_max_tokens(4096)
.with_temperature(0.7);
assert_eq!(request.model, "claude-opus-4-6");
assert_eq!(request.tools.len(), 2);
assert_eq!(request.max_tokens, 4096);
assert_eq!(request.temperature, Some(0.7));
}
}
#[cfg(all(feature = "skills", feature = "integration-real-tests"))]
mod external_source_lifecycle {
use std::collections::BTreeMap;
use meerkat_core::skills::{SkillFilter, SkillId, SkillSource};
use meerkat_skills::source::http::HttpExternalClient;
use meerkat_skills::source::protocol::{ExternalSkillSource, StdioExternalClient};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::test]
#[ignore = "lane:e2e-system"]
async fn e2e_external_source_lifecycle_stdio_and_http_like_transports() {
let stdio_script = r#"
read line
if echo "$line" | grep -q '"method":"capabilities/get"'; then
echo '{"jsonrpc":"2.0","id":"1","payload":{"method":"capabilities/get","result":{"protocol_version":1,"methods":["skills/list_summaries","skills/load_package","skills/list_artifacts","skills/read_artifact","skills/invoke_function"]}}}'
elif echo "$line" | grep -q '"method":"skills/list_summaries"'; then
echo '{"jsonrpc":"2.0","id":"1","payload":{"method":"skills/list_summaries","result":{"summaries":[{"source_uuid":"stdio-src","skill_name":"remote-skill","description":"Remote skill"}]}}}'
elif echo "$line" | grep -q '"method":"skills/load_package"'; then
echo '{"jsonrpc":"2.0","id":"1","payload":{"method":"skills/load_package","result":{"package":{"summary":{"source_uuid":"stdio-src","skill_name":"remote-skill","description":"Remote skill"},"body":"stdio-body"}}}}'
elif echo "$line" | grep -q '"method":"skills/list_artifacts"'; then
echo '{"jsonrpc":"2.0","id":"1","payload":{"method":"skills/list_artifacts","result":{"artifacts":[{"path":"README.md","mime_type":"text/markdown","byte_length":9}]}}}'
elif echo "$line" | grep -q '"method":"skills/read_artifact"'; then
echo '{"jsonrpc":"2.0","id":"1","payload":{"method":"skills/read_artifact","result":{"artifact":{"path":"README.md","mime_type":"text/markdown","content":"stdio-doc"}}}}'
elif echo "$line" | grep -q '"method":"skills/invoke_function"'; then
echo '{"jsonrpc":"2.0","id":"1","payload":{"method":"skills/invoke_function","result":{"output":{"transport":"stdio","ok":true}}}}'
fi
"#;
let stdio = ExternalSkillSource::new(
"stdio-src",
StdioExternalClient::new(
"sh",
vec!["-c".to_string(), stdio_script.to_string()],
BTreeMap::new(),
None,
),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(pair) => pair,
Err(_) => break,
};
tokio::spawn(async move {
let mut buf = vec![0_u8; 8192];
let n = match stream.read(&mut buf).await {
Ok(read) if read > 0 => read,
_ => return,
};
let request = String::from_utf8_lossy(&buf[..n]);
let first_line = request.lines().next().unwrap_or_default();
let response_payload = if first_line.starts_with("GET /capabilities ") {
json!({"method":"capabilities/get","result":{"protocol_version":1,"methods":["skills/list_summaries","skills/load_package","skills/list_artifacts","skills/read_artifact","skills/invoke_function"]}})
} else if first_line.starts_with("GET /skills ") {
json!({"method":"skills/list_summaries","result":{"summaries":[{"source_uuid":"http-src","skill_name":"remote-skill","description":"Remote skill"}]}})
} else if first_line
.contains("/skills/http-src%2Fremote-skill/functions/inspect")
&& first_line.starts_with("POST ")
{
json!({"method":"skills/invoke_function","result":{"output":{"transport":"http","ok":true}}})
} else if first_line
.contains("/skills/http-src%2Fremote-skill/artifacts/README.md")
{
json!({"method":"skills/read_artifact","result":{"artifact":{"path":"README.md","mime_type":"application/json","content":"{\"ok\":true}"}}})
} else if first_line.contains("/skills/http-src%2Fremote-skill/artifacts") {
json!({"method":"skills/list_artifacts","result":{"artifacts":[{"path":"README.md","mime_type":"application/json","byte_length":11}]}})
} else if first_line.contains("/skills/http-src%2Fremote-skill") {
json!({"method":"skills/load_package","result":{"package":{"summary":{"source_uuid":"http-src","skill_name":"remote-skill","description":"Remote skill"},"body":"http-body"}}})
} else {
json!({"error":"not found"})
};
let status = if response_payload.get("error").is_some() {
"404 Not Found"
} else {
"200 OK"
};
let body = response_payload.to_string();
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
});
}
});
let http = ExternalSkillSource::new(
"http-src",
HttpExternalClient::new("http-src", format!("http://{addr}"), None),
);
let stdio_skills = stdio.list(&SkillFilter::default()).await.unwrap();
let http_skills = http.list(&SkillFilter::default()).await.unwrap();
assert_eq!(stdio_skills[0].id.0, "remote-skill");
assert_eq!(http_skills[0].id.0, "remote-skill");
let stdio_doc = stdio
.load(&SkillId("remote-skill".to_string()))
.await
.unwrap();
let http_doc = http
.load(&SkillId("remote-skill".to_string()))
.await
.unwrap();
assert_eq!(stdio_doc.body, "stdio-body");
assert_eq!(http_doc.body, "http-body");
let stdio_artifacts = stdio
.list_artifacts(&SkillId("remote-skill".to_string()))
.await
.unwrap();
let http_artifacts = http
.list_artifacts(&SkillId("remote-skill".to_string()))
.await
.unwrap();
assert_eq!(stdio_artifacts[0].path, "README.md");
assert_eq!(http_artifacts[0].path, "README.md");
let stdio_artifact = stdio
.read_artifact(&SkillId("remote-skill".to_string()), "README.md")
.await
.unwrap();
let http_artifact = http
.read_artifact(&SkillId("remote-skill".to_string()), "README.md")
.await
.unwrap();
assert_eq!(stdio_artifact.content, "stdio-doc");
assert_eq!(http_artifact.content, "{\"ok\":true}");
let stdio_invoke = stdio
.invoke_function(
&SkillId("remote-skill".to_string()),
"inspect",
json!({"k":"v"}),
)
.await
.unwrap();
let http_invoke = http
.invoke_function(
&SkillId("remote-skill".to_string()),
"inspect",
json!({"k":"v"}),
)
.await
.unwrap();
assert_eq!(stdio_invoke["transport"], "stdio");
assert_eq!(http_invoke["transport"], "http");
server.abort();
}
}