feat: refactor sgclaw around zeroclaw compat runtime

This commit is contained in:
zyl
2026-03-26 16:23:31 +08:00
parent bca5b75801
commit ff0771a83f
1059 changed files with 409460 additions and 23 deletions

View File

@@ -0,0 +1,62 @@
//! Declarative expectation verification for trace fixtures.
use super::trace::TraceExpects;
/// Verify trace expectations against actual test results.
///
/// - `expects`: declarative expectations from the trace fixture
/// - `final_response`: the final text response from the agent
/// - `tools_called`: names of tools that were actually called
/// - `label`: test label for error messages
pub fn verify_expects(
expects: &TraceExpects,
final_response: &str,
tools_called: &[String],
label: &str,
) {
for needle in &expects.response_contains {
assert!(
final_response.contains(needle),
"[{label}] Expected response to contain \"{needle}\", got: {final_response}"
);
}
for needle in &expects.response_not_contains {
assert!(
!final_response.contains(needle),
"[{label}] Expected response NOT to contain \"{needle}\", got: {final_response}"
);
}
for tool in &expects.tools_used {
assert!(
tools_called.iter().any(|t| t == tool),
"[{label}] Expected tool \"{tool}\" to be used, but tools called were: {tools_called:?}"
);
}
for tool in &expects.tools_not_used {
assert!(
!tools_called.iter().any(|t| t == tool),
"[{label}] Expected tool \"{tool}\" NOT to be used, but it was called"
);
}
if let Some(max) = expects.max_tool_calls {
assert!(
tools_called.len() <= max,
"[{label}] Expected at most {max} tool calls, got {}",
tools_called.len()
);
}
for pattern in &expects.response_matches {
let re = regex::Regex::new(pattern).unwrap_or_else(|e| {
panic!("[{label}] Invalid regex pattern \"{pattern}\": {e}");
});
assert!(
re.is_match(final_response),
"[{label}] Expected response to match regex \"{pattern}\", got: {final_response}"
);
}
}

View File

@@ -0,0 +1,142 @@
//! Shared builder helpers for constructing test agents.
use anyhow::Result;
use async_trait::async_trait;
use std::sync::Arc;
use zeroclaw::agent::agent::Agent;
use zeroclaw::agent::dispatcher::{NativeToolDispatcher, XmlToolDispatcher};
use zeroclaw::agent::memory_loader::MemoryLoader;
use zeroclaw::config::MemoryConfig;
use zeroclaw::memory;
use zeroclaw::memory::Memory;
use zeroclaw::observability::{NoopObserver, Observer};
use zeroclaw::providers::{ChatResponse, Provider, ToolCall};
use zeroclaw::tools::Tool;
/// Create an in-memory "none" backend for tests.
pub fn make_memory() -> Arc<dyn Memory> {
let cfg = MemoryConfig {
backend: "none".into(),
..MemoryConfig::default()
};
Arc::from(memory::create_memory(&cfg, &std::env::temp_dir(), None).unwrap())
}
/// Create a `NoopObserver` for tests.
pub fn make_observer() -> Arc<dyn Observer> {
Arc::from(NoopObserver {})
}
/// Create a text-only `ChatResponse`.
pub fn text_response(text: &str) -> ChatResponse {
ChatResponse {
text: Some(text.into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}
}
/// Create a `ChatResponse` with tool calls.
pub fn tool_response(calls: Vec<ToolCall>) -> ChatResponse {
ChatResponse {
text: Some(String::new()),
tool_calls: calls,
usage: None,
reasoning_content: None,
}
}
/// Build an agent with `NativeToolDispatcher`.
pub fn build_agent(provider: Box<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Agent {
Agent::builder()
.provider(provider)
.tools(tools)
.memory(make_memory())
.observer(make_observer())
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::env::temp_dir())
.build()
.unwrap()
}
/// Build an agent with `XmlToolDispatcher`.
pub fn build_agent_xml(provider: Box<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Agent {
Agent::builder()
.provider(provider)
.tools(tools)
.memory(make_memory())
.observer(make_observer())
.tool_dispatcher(Box::new(XmlToolDispatcher))
.workspace_dir(std::env::temp_dir())
.build()
.unwrap()
}
/// Build an agent with optional custom `MemoryLoader`.
pub fn build_recording_agent(
provider: Box<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
memory_loader: Option<Box<dyn MemoryLoader>>,
) -> Agent {
let mut builder = Agent::builder()
.provider(provider)
.tools(tools)
.memory(make_memory())
.observer(make_observer())
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::env::temp_dir());
if let Some(loader) = memory_loader {
builder = builder.memory_loader(loader);
}
builder.build().unwrap()
}
/// Build an agent with real `SqliteMemory` in a temporary directory.
pub fn build_agent_with_sqlite_memory(
provider: Box<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
temp_dir: &std::path::Path,
) -> Agent {
let cfg = MemoryConfig {
backend: "sqlite".into(),
..MemoryConfig::default()
};
let mem = Arc::from(memory::create_memory(&cfg, temp_dir, None).unwrap());
Agent::builder()
.provider(provider)
.tools(tools)
.memory(mem)
.observer(make_observer())
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::env::temp_dir())
.build()
.unwrap()
}
/// Mock memory loader that returns a static context string.
pub struct StaticMemoryLoader {
context: String,
}
impl StaticMemoryLoader {
pub fn new(context: &str) -> Self {
Self {
context: context.to_string(),
}
}
}
#[async_trait]
impl MemoryLoader for StaticMemoryLoader {
async fn load_context(
&self,
_memory: &dyn Memory,
_user_message: &str,
_session_id: Option<&str>,
) -> Result<String> {
Ok(self.context.clone())
}
}

View File

@@ -0,0 +1,86 @@
//! Mock channel for system-level tests.
//!
//! `TestChannel` implements the `Channel` trait with MPSC-based message
//! injection and response capture for race-free testing.
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage};
/// A test channel that captures sent messages and supports message injection.
pub struct TestChannel {
name: String,
sent_messages: Arc<Mutex<Vec<SendMessage>>>,
typing_events: Arc<Mutex<Vec<TypingEvent>>>,
}
#[derive(Debug, Clone)]
pub enum TypingEvent {
Start(String),
Stop(String),
}
impl TestChannel {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
sent_messages: Arc::new(Mutex::new(Vec::new())),
typing_events: Arc::new(Mutex::new(Vec::new())),
}
}
/// Get all messages sent through this channel.
pub fn sent_messages(&self) -> Vec<SendMessage> {
self.sent_messages.lock().unwrap().clone()
}
/// Get all typing events recorded by this channel.
pub fn typing_events(&self) -> Vec<TypingEvent> {
self.typing_events.lock().unwrap().clone()
}
/// Clear captured messages and events.
pub fn clear(&self) {
self.sent_messages.lock().unwrap().clear();
self.typing_events.lock().unwrap().clear();
}
}
#[async_trait]
impl Channel for TestChannel {
fn name(&self) -> &str {
&self.name
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
self.sent_messages.lock().unwrap().push(message.clone());
Ok(())
}
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
// System tests drive the agent via turn() rather than channel listen,
// so this is a no-op. For channel-driven tests, messages are injected
// via the MPSC sender directly.
Ok(())
}
async fn health_check(&self) -> bool {
true
}
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.typing_events
.lock()
.unwrap()
.push(TypingEvent::Start(recipient.to_string()));
Ok(())
}
async fn stop_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.typing_events
.lock()
.unwrap()
.push(TypingEvent::Stop(recipient.to_string()));
Ok(())
}
}

View File

@@ -0,0 +1,199 @@
//! Shared mock provider implementations for integration tests.
use anyhow::Result;
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use zeroclaw::providers::traits::{ChatMessage, TokenUsage};
use zeroclaw::providers::{ChatRequest, ChatResponse, Provider, ToolCall};
use super::trace::{LlmTrace, TraceResponse};
/// Mock provider that returns scripted responses in FIFO order.
pub struct MockProvider {
responses: Mutex<Vec<ChatResponse>>,
}
impl MockProvider {
pub fn new(responses: Vec<ChatResponse>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
}
#[async_trait]
impl Provider for MockProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> Result<String> {
Ok("fallback".into())
}
async fn chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> Result<ChatResponse> {
let mut guard = self.responses.lock().unwrap();
if guard.is_empty() {
return Ok(ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
});
}
Ok(guard.remove(0))
}
}
/// Mock provider that returns scripted responses AND records every request.
pub struct RecordingProvider {
responses: Mutex<Vec<ChatResponse>>,
recorded_requests: Arc<Mutex<Vec<Vec<ChatMessage>>>>,
}
impl RecordingProvider {
pub fn new(responses: Vec<ChatResponse>) -> (Self, Arc<Mutex<Vec<Vec<ChatMessage>>>>) {
let recorded = Arc::new(Mutex::new(Vec::new()));
let provider = Self {
responses: Mutex::new(responses),
recorded_requests: recorded.clone(),
};
(provider, recorded)
}
}
#[async_trait]
impl Provider for RecordingProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> Result<String> {
Ok("fallback".into())
}
async fn chat(
&self,
request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> Result<ChatResponse> {
self.recorded_requests
.lock()
.unwrap()
.push(request.messages.to_vec());
let mut guard = self.responses.lock().unwrap();
if guard.is_empty() {
return Ok(ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
});
}
Ok(guard.remove(0))
}
}
/// Provider that replays responses from an `LlmTrace` fixture.
///
/// Each call to `chat()` returns the next step from the trace in FIFO order.
/// If the agent calls the provider more times than there are steps, an error is returned.
pub struct TraceLlmProvider {
steps: Mutex<Vec<TraceResponse>>,
trace_name: String,
}
impl TraceLlmProvider {
pub fn from_trace(trace: &LlmTrace) -> Self {
let mut steps = Vec::new();
for turn in &trace.turns {
for step in &turn.steps {
steps.push(step.response.clone());
}
}
Self {
steps: Mutex::new(steps),
trace_name: trace.model_name.clone(),
}
}
}
#[async_trait]
impl Provider for TraceLlmProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> Result<String> {
Ok("fallback".into())
}
async fn chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> Result<ChatResponse> {
let mut guard = self.steps.lock().unwrap();
if guard.is_empty() {
anyhow::bail!(
"TraceLlmProvider({}) exhausted: no more steps in trace",
self.trace_name
);
}
let step = guard.remove(0);
match step {
TraceResponse::Text {
content,
input_tokens,
output_tokens,
} => Ok(ChatResponse {
text: Some(content),
tool_calls: vec![],
usage: Some(TokenUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
cached_input_tokens: None,
}),
reasoning_content: None,
}),
TraceResponse::ToolCalls {
tool_calls,
input_tokens,
output_tokens,
} => {
let calls = tool_calls
.into_iter()
.map(|tc| ToolCall {
id: tc.id,
name: tc.name,
arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
})
.collect();
Ok(ChatResponse {
text: Some(String::new()),
tool_calls: calls,
usage: Some(TokenUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
cached_input_tokens: None,
}),
reasoning_content: None,
})
}
}
}
}

View File

@@ -0,0 +1,152 @@
//! Shared mock tool implementations for integration tests.
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use std::sync::{Arc, Mutex};
use zeroclaw::tools::{Tool, ToolResult};
/// Simple tool that echoes its input argument.
pub struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes the input message"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"message": {"type": "string"}
}
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let msg = args
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("(empty)")
.to_string();
Ok(ToolResult {
success: true,
output: msg,
error: None,
})
}
}
/// Tool that tracks invocation count for verifying dispatch.
pub struct CountingTool {
count: Arc<Mutex<usize>>,
}
impl CountingTool {
pub fn new() -> (Self, Arc<Mutex<usize>>) {
let count = Arc::new(Mutex::new(0));
(
Self {
count: count.clone(),
},
count,
)
}
}
#[async_trait]
impl Tool for CountingTool {
fn name(&self) -> &str {
"counter"
}
fn description(&self) -> &str {
"Counts invocations"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object"})
}
async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult> {
let mut c = self.count.lock().unwrap();
*c += 1;
Ok(ToolResult {
success: true,
output: format!("call #{}", *c),
error: None,
})
}
}
/// Tool that always fails, simulating a broken external service.
pub struct FailingTool;
#[async_trait]
impl Tool for FailingTool {
fn name(&self) -> &str {
"failing_tool"
}
fn description(&self) -> &str {
"Always fails"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object"})
}
async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult> {
Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Service unavailable: connection timeout".into()),
})
}
}
/// Tool that captures all arguments for assertion.
pub struct RecordingTool {
name: String,
calls: Arc<Mutex<Vec<serde_json::Value>>>,
}
impl RecordingTool {
pub fn new(name: &str) -> (Self, Arc<Mutex<Vec<serde_json::Value>>>) {
let calls = Arc::new(Mutex::new(Vec::new()));
(
Self {
name: name.to_string(),
calls: calls.clone(),
},
calls,
)
}
}
#[async_trait]
impl Tool for RecordingTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Records all arguments for assertion"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"input": {"type": "string"}
}
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
self.calls.lock().unwrap().push(args.clone());
let output = args
.get("input")
.and_then(|v| v.as_str())
.unwrap_or("recorded")
.to_string();
Ok(ToolResult {
success: true,
output,
error: None,
})
}
}

View File

@@ -0,0 +1,11 @@
#![allow(dead_code, unused_imports)]
pub mod assertions;
pub mod helpers;
pub mod mock_channel;
pub mod mock_provider;
pub mod mock_tools;
pub mod trace;
pub use mock_provider::{MockProvider, RecordingProvider};
pub use mock_tools::{CountingTool, EchoTool, FailingTool, RecordingTool};

View File

@@ -0,0 +1,84 @@
//! JSON trace fixture types for deterministic LLM response replay.
use serde::Deserialize;
use std::path::Path;
/// A complete LLM conversation trace loaded from a JSON fixture.
#[derive(Debug, Deserialize)]
pub struct LlmTrace {
pub model_name: String,
pub turns: Vec<TraceTurn>,
#[serde(default)]
pub expects: TraceExpects,
}
/// A single conversation turn (user input + LLM response steps).
#[derive(Debug, Deserialize)]
pub struct TraceTurn {
pub user_input: String,
pub steps: Vec<TraceStep>,
}
/// A single LLM response step within a turn.
#[derive(Debug, Deserialize)]
pub struct TraceStep {
pub response: TraceResponse,
}
/// The response content — either plain text or tool calls.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum TraceResponse {
#[serde(rename = "text")]
Text {
content: String,
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
},
#[serde(rename = "tool_calls")]
ToolCalls {
tool_calls: Vec<TraceToolCall>,
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
},
}
/// A tool call within a trace response.
#[derive(Debug, Clone, Deserialize)]
pub struct TraceToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
/// Declarative expectations for trace verification.
#[derive(Debug, Default, Deserialize)]
pub struct TraceExpects {
#[serde(default)]
pub response_contains: Vec<String>,
#[serde(default)]
pub response_not_contains: Vec<String>,
#[serde(default)]
pub tools_used: Vec<String>,
#[serde(default)]
pub tools_not_used: Vec<String>,
#[serde(default)]
pub max_tool_calls: Option<usize>,
#[serde(default)]
pub all_tools_succeeded: Option<bool>,
#[serde(default)]
pub response_matches: Vec<String>,
}
impl LlmTrace {
/// Load a trace from a JSON file.
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let trace: LlmTrace = serde_json::from_str(&content)?;
Ok(trace)
}
}