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,252 @@
//! TG2: Config Load/Save Round-Trip Tests
//!
//! Prevents: Pattern 2 — Config persistence & workspace discovery bugs (13% of user bugs).
//! Issues: #547, #417, #621, #802
//!
//! Tests Config::load_or_init() with isolated temp directories, env var overrides,
//! and config file round-trips to verify workspace discovery and persistence.
use std::fs;
use zeroclaw::config::{AgentConfig, Config, MemoryConfig};
// ─────────────────────────────────────────────────────────────────────────────
// Config default construction
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_default_has_expected_provider() {
let config = Config::default();
assert!(
config.default_provider.is_some(),
"default config should have a default_provider"
);
}
#[test]
fn config_default_has_expected_model() {
let config = Config::default();
assert!(
config.default_model.is_some(),
"default config should have a default_model"
);
}
#[test]
fn config_default_temperature_positive() {
let config = Config::default();
assert!(
config.default_temperature > 0.0,
"default temperature should be positive"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AgentConfig defaults
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn agent_config_default_max_tool_iterations() {
let agent = AgentConfig::default();
assert_eq!(
agent.max_tool_iterations, 10,
"default max_tool_iterations should be 10"
);
}
#[test]
fn agent_config_default_max_history_messages() {
let agent = AgentConfig::default();
assert_eq!(
agent.max_history_messages, 50,
"default max_history_messages should be 50"
);
}
#[test]
fn agent_config_default_tool_dispatcher() {
let agent = AgentConfig::default();
assert_eq!(
agent.tool_dispatcher, "auto",
"default tool_dispatcher should be 'auto'"
);
}
#[test]
fn agent_config_default_compact_context_on() {
let agent = AgentConfig::default();
assert!(
agent.compact_context,
"compact_context should default to true"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MemoryConfig defaults
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn memory_config_default_backend() {
let memory = MemoryConfig::default();
assert!(
!memory.backend.is_empty(),
"memory backend should have a default value"
);
}
#[test]
fn memory_config_default_embedding_provider() {
let memory = MemoryConfig::default();
// Default embedding_provider should be set (even if "none")
assert!(
!memory.embedding_provider.is_empty(),
"embedding_provider should have a default value"
);
}
#[test]
fn memory_config_default_vector_keyword_weights_sum_to_one() {
let memory = MemoryConfig::default();
let sum = memory.vector_weight + memory.keyword_weight;
assert!(
(sum - 1.0).abs() < 0.01,
"vector_weight + keyword_weight should sum to ~1.0, got {sum}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Config TOML serialization round-trip
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_toml_roundtrip_preserves_provider() {
let config = Config {
default_provider: Some("deepseek".into()),
default_model: Some("deepseek-chat".into()),
default_temperature: 0.5,
..Default::default()
};
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
assert_eq!(parsed.default_provider.as_deref(), Some("deepseek"));
assert_eq!(parsed.default_model.as_deref(), Some("deepseek-chat"));
assert!((parsed.default_temperature - 0.5).abs() < f64::EPSILON);
}
#[test]
fn config_toml_roundtrip_preserves_agent_config() {
let mut config = Config::default();
config.agent.max_tool_iterations = 5;
config.agent.max_history_messages = 25;
config.agent.compact_context = true;
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
assert_eq!(parsed.agent.max_tool_iterations, 5);
assert_eq!(parsed.agent.max_history_messages, 25);
assert!(parsed.agent.compact_context);
}
#[test]
fn config_toml_roundtrip_preserves_memory_config() {
let mut config = Config::default();
config.memory.embedding_provider = "openai".into();
config.memory.embedding_model = "text-embedding-3-small".into();
config.memory.vector_weight = 0.8;
config.memory.keyword_weight = 0.2;
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
assert_eq!(parsed.memory.embedding_provider, "openai");
assert_eq!(parsed.memory.embedding_model, "text-embedding-3-small");
assert!((parsed.memory.vector_weight - 0.8).abs() < f64::EPSILON);
assert!((parsed.memory.keyword_weight - 0.2).abs() < f64::EPSILON);
}
// ─────────────────────────────────────────────────────────────────────────────
// Config file write/read round-trip with tempdir
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_file_write_read_roundtrip() {
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
let config_path = tmp.path().join("config.toml");
let mut config = Config {
default_provider: Some("mistral".into()),
default_model: Some("mistral-large".into()),
..Default::default()
};
config.agent.max_tool_iterations = 15;
let toml_str = toml::to_string(&config).expect("config should serialize");
fs::write(&config_path, &toml_str).expect("config file write should succeed");
let read_back = fs::read_to_string(&config_path).expect("config file read should succeed");
let parsed: Config = toml::from_str(&read_back).expect("TOML should parse back");
assert_eq!(parsed.default_provider.as_deref(), Some("mistral"));
assert_eq!(parsed.default_model.as_deref(), Some("mistral-large"));
assert_eq!(parsed.agent.max_tool_iterations, 15);
}
#[test]
fn config_file_with_missing_optional_fields_uses_defaults() {
// Simulate a minimal config TOML that omits optional sections
let minimal_toml = r#"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal_toml).expect("minimal TOML should parse");
// Agent config should use defaults
assert_eq!(parsed.agent.max_tool_iterations, 10);
assert_eq!(parsed.agent.max_history_messages, 50);
assert!(parsed.agent.compact_context);
}
#[test]
fn config_file_with_custom_agent_section() {
let toml_with_agent = r#"
default_temperature = 0.7
[agent]
max_tool_iterations = 3
compact_context = true
"#;
let parsed: Config =
toml::from_str(toml_with_agent).expect("TOML with agent section should parse");
assert_eq!(parsed.agent.max_tool_iterations, 3);
assert!(parsed.agent.compact_context);
// max_history_messages should still use default
assert_eq!(parsed.agent.max_history_messages, 50);
}
// ─────────────────────────────────────────────────────────────────────────────
// Workspace directory creation
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn workspace_dir_creation_in_tempdir() {
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
let workspace_dir = tmp.path().join("workspace");
fs::create_dir_all(&workspace_dir).expect("workspace dir creation should succeed");
assert!(workspace_dir.exists(), "workspace dir should exist");
assert!(
workspace_dir.is_dir(),
"workspace path should be a directory"
);
}
#[test]
fn nested_workspace_dir_creation() {
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
let nested_dir = tmp.path().join("deep").join("nested").join("workspace");
fs::create_dir_all(&nested_dir).expect("nested dir creation should succeed");
assert!(nested_dir.exists(), "nested workspace dir should exist");
}

View File

@@ -0,0 +1,522 @@
//! Config Schema Boundary Tests
//!
//! Validates: config defaults, backward compatibility, invalid input rejection,
//! and gateway/security/agent config boundary conditions.
use zeroclaw::config::{AutonomyConfig, ChannelsConfig, Config, GatewayConfig, SecurityConfig};
// ─────────────────────────────────────────────────────────────────────────────
// Invalid value fail-fast
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_unknown_keys_parse_without_error() {
let toml_str = r#"
default_temperature = 0.7
totally_unknown_key = "should be ignored"
another_fake = 42
"#;
let parsed: Config = toml::from_str(toml_str).expect("unknown keys should be ignored");
assert!((parsed.default_temperature - 0.7).abs() < f64::EPSILON);
}
#[test]
fn config_wrong_type_for_port_fails() {
let toml_str = r#"
[gateway]
port = "not_a_number"
"#;
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err(), "string for u16 port should fail to parse");
}
#[test]
fn config_wrong_type_for_temperature_fails() {
let toml_str = r#"
default_temperature = "hot"
"#;
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(
result.is_err(),
"string for f64 temperature should fail to parse"
);
}
#[test]
fn config_out_of_range_temperature_fails() {
let toml_str = "default_temperature = 99.0\n";
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(
result.is_err(),
"temperature 99.0 should be rejected at deserialization"
);
}
#[test]
fn config_negative_temperature_fails() {
let toml_str = "default_temperature = -0.5\n";
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(
result.is_err(),
"negative temperature should be rejected at deserialization"
);
}
#[test]
fn config_negative_port_fails() {
let toml_str = r#"
[gateway]
port = -1
"#;
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err(), "negative port should fail for u16");
}
#[test]
fn config_overflow_port_fails() {
let toml_str = r#"
[gateway]
port = 99999
"#;
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err(), "port > 65535 should fail for u16");
}
// ─────────────────────────────────────────────────────────────────────────────
// GatewayConfig boundary tests
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn gateway_config_defaults_are_secure() {
let gw = GatewayConfig::default();
assert_eq!(gw.port, 42617);
assert_eq!(gw.host, "127.0.0.1");
assert!(gw.require_pairing, "pairing should be required by default");
assert!(
!gw.allow_public_bind,
"public bind should be denied by default"
);
assert!(
!gw.trust_forwarded_headers,
"forwarded headers should be untrusted by default"
);
assert!(
gw.path_prefix.is_none(),
"path_prefix should default to None"
);
}
#[test]
fn gateway_config_rate_limit_defaults() {
let gw = GatewayConfig::default();
assert_eq!(gw.pair_rate_limit_per_minute, 10);
assert_eq!(gw.webhook_rate_limit_per_minute, 60);
assert_eq!(gw.rate_limit_max_keys, 10_000);
}
#[test]
fn gateway_config_idempotency_defaults() {
let gw = GatewayConfig::default();
assert_eq!(gw.idempotency_ttl_secs, 300);
assert_eq!(gw.idempotency_max_keys, 10_000);
}
#[test]
fn gateway_config_toml_roundtrip() {
let gw = GatewayConfig {
port: 8080,
host: "0.0.0.0".into(),
require_pairing: false,
pair_rate_limit_per_minute: 5,
path_prefix: Some("/zeroclaw".into()),
..Default::default()
};
let toml_str = toml::to_string(&gw).expect("gateway config should serialize");
let parsed: GatewayConfig = toml::from_str(&toml_str).expect("should deserialize back");
assert_eq!(parsed.port, 8080);
assert_eq!(parsed.host, "0.0.0.0");
assert!(!parsed.require_pairing);
assert_eq!(parsed.pair_rate_limit_per_minute, 5);
assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
}
#[test]
fn gateway_config_missing_section_uses_defaults() {
let toml_str = r#"
default_temperature = 0.5
"#;
let parsed: Config = toml::from_str(toml_str).expect("missing gateway section should parse");
assert_eq!(parsed.gateway.port, 42617);
assert_eq!(parsed.gateway.host, "127.0.0.1");
assert!(parsed.gateway.require_pairing);
assert!(!parsed.gateway.allow_public_bind);
}
#[test]
fn gateway_config_partial_section_fills_defaults() {
let toml_str = r#"
default_temperature = 0.7
[gateway]
port = 9090
"#;
let parsed: Config = toml::from_str(toml_str).expect("partial gateway should parse");
assert_eq!(parsed.gateway.port, 9090);
assert_eq!(parsed.gateway.host, "127.0.0.1");
assert!(parsed.gateway.require_pairing);
assert_eq!(parsed.gateway.pair_rate_limit_per_minute, 10);
}
// ─────────────────────────────────────────────────────────────────────────────
// GatewayConfig path_prefix validation
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn gateway_path_prefix_rejects_missing_leading_slash() {
let mut config = Config::default();
config.gateway.path_prefix = Some("zeroclaw".into());
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("must start with '/'"),
"expected leading-slash error, got: {err}"
);
}
#[test]
fn gateway_path_prefix_rejects_trailing_slash() {
let mut config = Config::default();
config.gateway.path_prefix = Some("/zeroclaw/".into());
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("must not end with '/'"),
"expected trailing-slash error, got: {err}"
);
}
#[test]
fn gateway_path_prefix_rejects_bare_slash() {
let mut config = Config::default();
config.gateway.path_prefix = Some("/".into());
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("must not end with '/'"),
"expected bare-slash error, got: {err}"
);
}
#[test]
fn gateway_path_prefix_accepts_valid_prefixes() {
for prefix in ["/zeroclaw", "/apps/zeroclaw", "/api/hassio_ingress/abc123"] {
let mut config = Config::default();
config.gateway.path_prefix = Some(prefix.into());
config
.validate()
.unwrap_or_else(|e| panic!("prefix {prefix:?} should be valid, got: {e}"));
}
}
#[test]
fn gateway_path_prefix_rejects_unsafe_characters() {
for prefix in [
"/zero claw",
"/zero<claw",
"/zero>claw",
"/zero\"claw",
"/zero?query",
"/zero#frag",
] {
let mut config = Config::default();
config.gateway.path_prefix = Some(prefix.into());
let err = config.validate().unwrap_err();
assert!(
err.to_string().contains("invalid character"),
"prefix {prefix:?} should be rejected, got: {err}"
);
}
// Leading/trailing whitespace is rejected by the starts_with('/') or
// invalid-character check — either way it must not pass validation.
for prefix in [" /zeroclaw ", " /zeroclaw"] {
let mut config = Config::default();
config.gateway.path_prefix = Some(prefix.into());
assert!(
config.validate().is_err(),
"whitespace-padded prefix {prefix:?} should be rejected"
);
}
}
#[test]
fn gateway_path_prefix_accepts_none() {
let config = Config::default();
assert!(config.gateway.path_prefix.is_none());
config
.validate()
.expect("absent path_prefix should be valid");
}
// ─────────────────────────────────────────────────────────────────────────────
// SecurityConfig boundary tests
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn security_config_defaults() {
let sec = SecurityConfig::default();
assert!(
sec.sandbox.enabled.is_none(),
"sandbox enabled should auto-detect (None) by default"
);
assert!(sec.audit.enabled, "audit should be enabled by default");
}
#[test]
fn security_config_toml_roundtrip() {
let mut sec = SecurityConfig::default();
sec.sandbox.enabled = Some(true);
sec.audit.max_size_mb = 200;
let toml_str = toml::to_string(&sec).expect("SecurityConfig should serialize");
let parsed: SecurityConfig = toml::from_str(&toml_str).expect("should deserialize back");
assert_eq!(parsed.sandbox.enabled, Some(true));
assert_eq!(parsed.audit.max_size_mb, 200);
}
// ─────────────────────────────────────────────────────────────────────────────
// AutonomyConfig boundary tests (security policy via Config.autonomy)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn autonomy_config_default_is_supervised() {
let autonomy = AutonomyConfig::default();
assert_eq!(
format!("{:?}", autonomy.level),
"Supervised",
"default autonomy should be Supervised"
);
}
#[test]
fn autonomy_config_default_max_actions_per_hour() {
let autonomy = AutonomyConfig::default();
assert!(
autonomy.max_actions_per_hour > 0,
"max_actions_per_hour should be positive"
);
}
#[test]
fn autonomy_config_default_workspace_only() {
let autonomy = AutonomyConfig::default();
assert!(
autonomy.workspace_only,
"workspace_only should default to true"
);
}
#[test]
fn autonomy_config_toml_roundtrip() {
let mut config = Config::default();
config.autonomy.max_actions_per_hour = 50;
config.autonomy.workspace_only = false;
let toml_str = toml::to_string(&config).expect("config should serialize");
let parsed: Config = toml::from_str(&toml_str).expect("should deserialize back");
assert_eq!(parsed.autonomy.max_actions_per_hour, 50);
assert!(!parsed.autonomy.workspace_only);
}
// ─────────────────────────────────────────────────────────────────────────────
// Backward compatibility
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_empty_toml_uses_default_temperature() {
let result: Result<Config, _> = toml::from_str("");
assert!(
result.is_ok(),
"empty TOML should succeed and use default temperature"
);
let config = result.unwrap();
assert!((config.default_temperature - 0.7).abs() < f64::EPSILON);
}
#[test]
fn config_minimal_toml_with_temperature_uses_defaults() {
let toml_str = "default_temperature = 0.7\n";
let parsed: Config = toml::from_str(toml_str).expect("minimal TOML should parse");
assert_eq!(parsed.agent.max_tool_iterations, 10);
assert_eq!(parsed.gateway.port, 42617);
}
#[test]
fn config_only_temperature_parses() {
let toml_str = "default_temperature = 1.2\n";
let parsed: Config = toml::from_str(toml_str).expect("temperature-only TOML should parse");
assert!((parsed.default_temperature - 1.2).abs() < f64::EPSILON);
assert_eq!(parsed.agent.max_tool_iterations, 10);
}
#[test]
fn config_extra_unknown_keys_ignored() {
let toml_str = r#"
default_temperature = 0.5
future_feature = true
[some_future_section]
value = 123
"#;
let parsed: Config =
toml::from_str(toml_str).expect("unknown keys and sections should be ignored");
assert!((parsed.default_temperature - 0.5).abs() < f64::EPSILON);
}
// ─────────────────────────────────────────────────────────────────────────────
// Config merging edge cases
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_multiple_channels_coexist() {
let toml_str = r#"
default_temperature = 0.7
[channels_config.telegram]
bot_token = "test_token"
allowed_users = ["zeroclaw_user"]
[channels_config.discord]
bot_token = "test_token"
"#;
let parsed: Config = toml::from_str(toml_str).expect("multi-channel config should parse");
assert!(parsed.channels_config.telegram.is_some());
assert!(parsed.channels_config.discord.is_some());
assert!(parsed.channels_config.slack.is_none());
}
#[test]
fn config_nested_optional_sections_default_when_absent() {
let toml_str = "default_temperature = 0.7\n";
let parsed: Config = toml::from_str(toml_str).expect("minimal TOML should parse");
assert!(parsed.channels_config.telegram.is_none());
assert!(!parsed.composio.enabled);
assert!(parsed.composio.api_key.is_none());
assert!(parsed.browser.enabled);
}
#[test]
fn config_channels_default_cli_enabled() {
let channels = ChannelsConfig::default();
assert!(channels.cli, "CLI channel should be enabled by default");
}
#[test]
fn config_channels_all_optional_channels_none_by_default() {
let channels = ChannelsConfig::default();
assert!(channels.telegram.is_none());
assert!(channels.discord.is_none());
assert!(channels.slack.is_none());
assert!(channels.matrix.is_none());
assert!(channels.lark.is_none());
assert!(channels.feishu.is_none());
assert!(channels.webhook.is_none());
}
#[test]
fn config_memory_defaults_when_section_absent() {
let toml_str = "default_temperature = 0.7\n";
let parsed: Config = toml::from_str(toml_str).expect("minimal TOML should parse");
let mem = &parsed.memory;
assert!(!mem.backend.is_empty());
assert!(!mem.embedding_provider.is_empty());
let weight_sum = mem.vector_weight + mem.keyword_weight;
assert!(
(weight_sum - 1.0).abs() < 0.01,
"vector + keyword weights should sum to ~1.0"
);
}
#[test]
fn config_channels_without_cli_field() {
let toml_str = r#"
default_temperature = 0.7
[channels_config.matrix]
homeserver = "https://matrix.example.com"
access_token = "syt_test_token"
room_id = "!abc123:example.com"
allowed_users = ["@user:example.com"]
"#;
let parsed: Config = toml::from_str(toml_str)
.expect("channels_config with only a Matrix section (no explicit cli field) should parse");
assert!(
parsed.channels_config.cli,
"cli should default to true when omitted"
);
assert!(parsed.channels_config.matrix.is_some());
}
// ─────────────────────────────────────────────────────────────────────────────
// Issue #3456 top-level [cli] section must not clash with channels_config.cli
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_toplevel_cli_section_with_whatsapp_parses() {
// Exact config from issue #3456
let toml_str = r#"
[cli]
[channels_config.whatsapp]
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
allowed_numbers = ["*"]
"#;
let parsed: Config = toml::from_str(toml_str)
.expect("top-level [cli] section with [channels_config.whatsapp] should parse");
assert!(parsed.channels_config.whatsapp.is_some());
let wa = parsed.channels_config.whatsapp.unwrap();
assert_eq!(
wa.session_path.as_deref(),
Some("~/.zeroclaw/state/whatsapp-web/session.db")
);
assert_eq!(wa.allowed_numbers, vec!["*".to_string()]);
}
#[test]
fn config_only_whatsapp_channel_parses() {
let toml_str = r#"
[channels_config.whatsapp]
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
allowed_numbers = ["*"]
"#;
let parsed: Config =
toml::from_str(toml_str).expect("config with only whatsapp channel should parse");
assert!(parsed.channels_config.whatsapp.is_some());
assert!(
parsed.channels_config.cli,
"cli should default to true when omitted"
);
}
#[test]
fn config_channels_explicit_cli_true_with_whatsapp() {
let toml_str = r#"
[channels_config]
cli = true
[channels_config.whatsapp]
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
allowed_numbers = ["*"]
"#;
let parsed: Config = toml::from_str(toml_str)
.expect("explicit channels_config.cli=true with whatsapp should parse");
assert!(parsed.channels_config.cli);
assert!(parsed.channels_config.whatsapp.is_some());
}
#[test]
fn config_empty_parses_with_all_defaults() {
let parsed: Config = toml::from_str("").expect("empty config should parse with all defaults");
assert!(parsed.channels_config.cli);
assert!(parsed.channels_config.whatsapp.is_none());
assert!((parsed.default_temperature - 0.7).abs() < f64::EPSILON);
}

View File

@@ -0,0 +1,344 @@
//! Tests to verify .dockerignore excludes sensitive paths from Docker build context.
//!
//! These tests validate that:
//! 1. The .dockerignore file exists
//! 2. All security-critical paths are excluded
//! 3. All build-essential paths are NOT excluded
//! 4. Pattern syntax is valid
use std::path::Path;
/// Paths that MUST be excluded from Docker build context (security/performance)
const MUST_EXCLUDE: &[&str] = &[
".git",
".githooks",
"target",
"docs",
"examples",
"tests",
"*.md",
"*.png",
"*.db",
"*.db-journal",
".DS_Store",
".github",
"deny.toml",
"LICENSE",
".env",
".tmp_*",
];
/// Paths that MUST NOT be excluded (required for build)
const MUST_INCLUDE: &[&str] = &["Cargo.toml", "Cargo.lock", "src/"];
/// Parse .dockerignore and return all non-comment, non-empty lines
fn parse_dockerignore(content: &str) -> Vec<String> {
content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.to_string())
.collect()
}
/// Check if a pattern would match a given path
fn pattern_matches(pattern: &str, path: &str) -> bool {
// Handle negation patterns
if pattern.starts_with('!') {
return false; // Negation re-includes, so it doesn't "exclude"
}
// Handle glob patterns
if pattern.starts_with("*.") {
let ext = &pattern[1..]; // e.g., ".md"
return path.ends_with(ext);
}
// Handle directory patterns (with or without trailing slash)
let pattern_normalized = pattern.trim_end_matches('/');
let path_normalized = path.trim_end_matches('/');
// Exact match
if path_normalized == pattern_normalized {
return true;
}
// Pattern is a prefix (directory match)
if path_normalized.starts_with(&format!("{}/", pattern_normalized)) {
return true;
}
// Wildcard prefix patterns like ".tmp_*"
if pattern.contains('*') && !pattern.starts_with("*.") {
let prefix = pattern.split('*').next().unwrap_or("");
if !prefix.is_empty() && path.starts_with(prefix) {
return true;
}
}
false
}
/// Check if any pattern in the list would exclude the given path
fn is_excluded(patterns: &[String], path: &str) -> bool {
let mut excluded = false;
for pattern in patterns {
if let Some(negated) = pattern.strip_prefix('!') {
// Negation pattern - re-include
if pattern_matches(negated, path) {
excluded = false;
}
} else if pattern_matches(pattern, path) {
excluded = true;
}
}
excluded
}
#[tokio::test]
async fn dockerignore_file_exists() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
assert!(
path.exists(),
".dockerignore file must exist at project root"
);
}
#[tokio::test]
async fn dockerignore_excludes_security_critical_paths() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
for must_exclude in MUST_EXCLUDE {
// For glob patterns, test with a sample file
let test_path = if must_exclude.starts_with("*.") {
format!("sample{}", &must_exclude[1..])
} else {
must_exclude.to_string()
};
assert!(
is_excluded(&patterns, &test_path),
"Path '{}' (tested as '{}') MUST be excluded by .dockerignore but is not. \
This is a security/performance issue.",
must_exclude,
test_path
);
}
}
#[tokio::test]
async fn dockerignore_does_not_exclude_build_essentials() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
for must_include in MUST_INCLUDE {
assert!(
!is_excluded(&patterns, must_include),
"Path '{}' MUST NOT be excluded by .dockerignore (required for build)",
must_include
);
}
}
#[tokio::test]
async fn dockerignore_excludes_git_directory() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
// .git directory and its contents must be excluded
assert!(is_excluded(&patterns, ".git"), ".git must be excluded");
assert!(
is_excluded(&patterns, ".git/config"),
".git/config must be excluded"
);
assert!(
is_excluded(&patterns, ".git/objects/pack/pack-abc123.pack"),
".git subdirectories must be excluded"
);
}
#[tokio::test]
async fn dockerignore_excludes_target_directory() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(is_excluded(&patterns, "target"), "target must be excluded");
assert!(
is_excluded(&patterns, "target/debug/zeroclaw"),
"target/debug must be excluded"
);
assert!(
is_excluded(&patterns, "target/release/zeroclaw"),
"target/release must be excluded"
);
}
#[tokio::test]
async fn dockerignore_excludes_database_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, "brain.db"),
"*.db files must be excluded"
);
assert!(
is_excluded(&patterns, "memory.db"),
"*.db files must be excluded"
);
assert!(
is_excluded(&patterns, "brain.db-journal"),
"*.db-journal files must be excluded"
);
}
#[tokio::test]
async fn dockerignore_excludes_markdown_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, "README.md"),
"*.md files must be excluded"
);
assert!(
is_excluded(&patterns, "CHANGELOG.md"),
"*.md files must be excluded"
);
assert!(
is_excluded(&patterns, "CONTRIBUTING.md"),
"*.md files must be excluded"
);
}
#[tokio::test]
async fn dockerignore_excludes_image_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, "zeroclaw.png"),
"*.png files must be excluded"
);
assert!(
is_excluded(&patterns, "logo.png"),
"*.png files must be excluded"
);
}
#[tokio::test]
async fn dockerignore_excludes_env_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, ".env"),
".env must be excluded (contains secrets)"
);
}
#[tokio::test]
async fn dockerignore_excludes_ci_configs() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, ".github"),
".github must be excluded"
);
assert!(
is_excluded(&patterns, ".github/workflows/ci.yml"),
".github/workflows must be excluded"
);
}
#[tokio::test]
async fn dockerignore_has_valid_syntax() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = tokio::fs::read_to_string(&path)
.await
.expect("Failed to read .dockerignore");
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
// Skip empty lines and comments
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Check for invalid patterns
assert!(
!trimmed.contains("**") || trimmed.matches("**").count() <= 2,
"Line {}: Too many ** in pattern '{}'",
line_num + 1,
trimmed
);
// Check for trailing spaces (can cause issues)
assert!(
line.trim_end() == line.trim_start().trim_end(),
"Line {}: Pattern '{}' has leading whitespace which may cause issues",
line_num + 1,
line
);
}
}
#[tokio::test]
async fn dockerignore_pattern_matching_edge_cases() {
// Test the pattern matching logic itself
let patterns = vec![
".git".to_string(),
".githooks".to_string(),
"target".to_string(),
"*.md".to_string(),
"*.db".to_string(),
".tmp_*".to_string(),
".env".to_string(),
];
// Should match
assert!(is_excluded(&patterns, ".git"));
assert!(is_excluded(&patterns, ".git/config"));
assert!(is_excluded(&patterns, ".githooks"));
assert!(is_excluded(&patterns, "target"));
assert!(is_excluded(&patterns, "target/debug/build"));
assert!(is_excluded(&patterns, "README.md"));
assert!(is_excluded(&patterns, "brain.db"));
assert!(is_excluded(&patterns, ".env"));
// Should NOT match
assert!(!is_excluded(&patterns, "src"));
assert!(!is_excluded(&patterns, "src/main.rs"));
assert!(!is_excluded(&patterns, "Cargo.toml"));
assert!(!is_excluded(&patterns, "Cargo.lock"));
}

View File

@@ -0,0 +1,158 @@
//! Gateway component tests.
//!
//! Tests public gateway infrastructure (rate limiter, idempotency, signature
//! verification) in isolation. The gateway module (`zeroclaw::gateway`) exposes
//! `verify_whatsapp_signature` and the server function `run_gateway`, but the
//! internal rate limiter and idempotency store constructors are crate-private.
//! Tests here verify behavior through the public API surface.
use zeroclaw::gateway::verify_whatsapp_signature;
// ═════════════════════════════════════════════════════════════════════════════
// WhatsApp webhook signature verification (public API)
// ═════════════════════════════════════════════════════════════════════════════
/// Valid HMAC-SHA256 signature is accepted.
#[test]
fn gateway_whatsapp_valid_signature_accepted() {
let secret = "test_app_secret";
let body = b"test body content";
// Compute expected signature
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
let signature = hex::encode(result.into_bytes());
let header = format!("sha256={signature}");
assert!(
verify_whatsapp_signature(secret, body, &header),
"Valid signature should be accepted"
);
}
/// Wrong signature is rejected.
#[test]
fn gateway_whatsapp_wrong_signature_rejected() {
let secret = "test_app_secret";
let body = b"test body content";
let header = "sha256=0000000000000000000000000000000000000000000000000000000000000000";
assert!(
!verify_whatsapp_signature(secret, body, header),
"Wrong signature should be rejected"
);
}
/// Missing sha256= prefix is rejected.
#[test]
fn gateway_whatsapp_missing_prefix_rejected() {
let secret = "test_app_secret";
let body = b"test body content";
let header = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
assert!(
!verify_whatsapp_signature(secret, body, header),
"Missing sha256= prefix should be rejected"
);
}
/// Empty signature is rejected.
#[test]
fn gateway_whatsapp_empty_signature_rejected() {
let secret = "test_app_secret";
let body = b"test body content";
assert!(
!verify_whatsapp_signature(secret, body, ""),
"Empty signature should be rejected"
);
}
/// Tampered body is rejected (signature computed for different body).
#[test]
fn gateway_whatsapp_tampered_body_rejected() {
let secret = "test_app_secret";
let original_body = b"original body";
let tampered_body = b"tampered body";
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(original_body);
let result = mac.finalize();
let signature = hex::encode(result.into_bytes());
let header = format!("sha256={signature}");
assert!(
!verify_whatsapp_signature(secret, tampered_body, &header),
"Tampered body should be rejected"
);
}
/// Different secrets produce different signatures.
#[test]
fn gateway_whatsapp_different_secrets_differ() {
let body = b"same body";
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut mac1 = HmacSha256::new_from_slice(b"secret_one").unwrap();
mac1.update(body);
let sig1 = hex::encode(mac1.finalize().into_bytes());
let mut mac2 = HmacSha256::new_from_slice(b"secret_two").unwrap();
mac2.update(body);
let sig2 = hex::encode(mac2.finalize().into_bytes());
assert_ne!(
sig1, sig2,
"Different secrets should produce different signatures"
);
let header1 = format!("sha256={sig1}");
assert!(verify_whatsapp_signature("secret_one", body, &header1));
assert!(!verify_whatsapp_signature("secret_two", body, &header1));
}
// ═════════════════════════════════════════════════════════════════════════════
// Gateway constants and configuration validation
// ═════════════════════════════════════════════════════════════════════════════
/// Gateway body limit constant is reasonable.
#[test]
fn gateway_body_limit_is_reasonable() {
assert_eq!(
zeroclaw::gateway::MAX_BODY_SIZE,
65_536,
"Max body size should be 64KB"
);
}
/// Gateway timeout constant is reasonable.
#[test]
fn gateway_timeout_is_reasonable() {
assert_eq!(
zeroclaw::gateway::REQUEST_TIMEOUT_SECS,
30,
"Request timeout should be 30 seconds"
);
}
/// Gateway rate limit window is 60 seconds.
#[test]
fn gateway_rate_limit_window_is_60s() {
assert_eq!(
zeroclaw::gateway::RATE_LIMIT_WINDOW_SECS,
60,
"Rate limit window should be 60 seconds"
);
}

View File

@@ -0,0 +1,79 @@
//! Gemini provider capabilities and contract tests.
//!
//! Validates that the Gemini provider correctly declares its capabilities
//! through the public Provider trait, ensuring the agent loop selects the
//! right tool-calling strategy (prompt-guided, not native).
use zeroclaw::providers::create_provider_with_url;
use zeroclaw::providers::traits::Provider;
fn gemini_provider() -> Box<dyn Provider> {
create_provider_with_url("gemini", Some("test-key"), None)
.expect("Gemini provider should resolve with test key")
}
// ─────────────────────────────────────────────────────────────────────────────
// Capabilities declaration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn gemini_reports_no_native_tool_calling() {
let provider = gemini_provider();
let caps = provider.capabilities();
assert!(
!caps.native_tool_calling,
"Gemini should use prompt-guided tool calling, not native"
);
}
#[test]
fn gemini_reports_vision_support() {
let provider = gemini_provider();
let caps = provider.capabilities();
assert!(caps.vision, "Gemini should report vision support");
}
#[test]
fn gemini_supports_native_tools_returns_false() {
let provider = gemini_provider();
assert!(
!provider.supports_native_tools(),
"supports_native_tools() must be false to trigger prompt-guided fallback in chat()"
);
}
#[test]
fn gemini_supports_vision_returns_true() {
let provider = gemini_provider();
assert!(provider.supports_vision());
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool conversion contract
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn gemini_convert_tools_returns_prompt_guided() {
use zeroclaw::providers::traits::ToolsPayload;
use zeroclaw::tools::traits::ToolSpec;
let provider = gemini_provider();
let tools = vec![ToolSpec {
name: "memory_store".to_string(),
description: "Store a value in memory".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"key": {"type": "string"},
"value": {"type": "string"}
},
"required": ["key", "value"]
}),
}];
let payload = provider.convert_tools(&tools);
assert!(
matches!(payload, ToolsPayload::PromptGuided { .. }),
"Gemini should return PromptGuided payload since native_tool_calling is false"
);
}

View File

@@ -0,0 +1,11 @@
mod config_persistence;
mod config_schema;
mod dockerignore_test;
mod gateway;
mod gemini_capabilities;
mod otel_dependency_feature_regression;
mod provider_resolution;
mod provider_schema;
mod reply_target_field_regression;
mod security;
mod whatsapp_webhook_security;

View File

@@ -0,0 +1,17 @@
#[test]
fn opentelemetry_otlp_uses_blocking_reqwest_client() {
let manifest = include_str!("../../Cargo.toml");
let otlp_line = manifest
.lines()
.find(|line: &&str| line.trim_start().starts_with("opentelemetry-otlp ="))
.expect("Cargo.toml must define opentelemetry-otlp dependency");
assert!(
otlp_line.contains("\"reqwest-blocking-client\""),
"opentelemetry-otlp must include reqwest-blocking-client to avoid Tokio reactor panics"
);
assert!(
!otlp_line.contains("\"reqwest-client\""),
"opentelemetry-otlp must not include async reqwest-client in this runtime mode"
);
}

View File

@@ -0,0 +1,479 @@
//! TG1: Provider End-to-End Resolution Tests
//!
//! Prevents: Pattern 1 — Provider configuration & resolution bugs (27% of user bugs).
//! Issues: #831, #834, #721, #580, #452, #451, #796, #843
//!
//! Tests the full pipeline from config values through `create_provider_with_url()`
//! to provider construction, verifying factory resolution, URL construction,
//! credential wiring, and auth header format.
use zeroclaw::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
use zeroclaw::providers::{
create_provider, create_provider_with_options, create_provider_with_url,
};
/// Helper: assert provider creation succeeds
fn assert_provider_ok(name: &str, key: Option<&str>, url: Option<&str>) {
let result = create_provider_with_url(name, key, url);
assert!(
result.is_ok(),
"{name} provider should resolve: {}",
result.err().map(|e| e.to_string()).unwrap_or_default()
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Factory resolution: each major provider name resolves without error
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_resolves_openai_provider() {
assert_provider_ok("openai", Some("test-key"), None);
}
#[test]
fn factory_resolves_anthropic_provider() {
assert_provider_ok("anthropic", Some("test-key"), None);
}
#[test]
fn factory_resolves_deepseek_provider() {
assert_provider_ok("deepseek", Some("test-key"), None);
}
#[test]
fn factory_resolves_mistral_provider() {
assert_provider_ok("mistral", Some("test-key"), None);
}
#[test]
fn factory_resolves_ollama_provider() {
assert_provider_ok("ollama", None, None);
}
#[test]
fn factory_resolves_groq_provider() {
assert_provider_ok("groq", Some("test-key"), None);
}
#[test]
fn factory_resolves_xai_provider() {
assert_provider_ok("xai", Some("test-key"), None);
}
#[test]
fn factory_resolves_together_provider() {
assert_provider_ok("together", Some("test-key"), None);
}
#[test]
fn factory_resolves_fireworks_provider() {
assert_provider_ok("fireworks", Some("test-key"), None);
}
#[test]
fn factory_resolves_perplexity_provider() {
assert_provider_ok("perplexity", Some("test-key"), None);
}
// ─────────────────────────────────────────────────────────────────────────────
// Factory resolution: alias variants map to same provider
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_grok_alias_resolves_to_xai() {
assert_provider_ok("grok", Some("test-key"), None);
}
#[test]
fn factory_kimi_alias_resolves_to_moonshot() {
assert_provider_ok("kimi", Some("test-key"), None);
}
#[test]
fn factory_zhipu_alias_resolves_to_glm() {
assert_provider_ok("zhipu", Some("test-key"), None);
}
// ─────────────────────────────────────────────────────────────────────────────
// Custom URL provider creation
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_custom_http_url_resolves() {
assert_provider_ok("custom:http://localhost:8080", Some("test-key"), None);
}
#[test]
fn factory_custom_https_url_resolves() {
assert_provider_ok("custom:https://api.example.com/v1", Some("test-key"), None);
}
#[test]
fn factory_custom_ftp_url_rejected() {
let result = create_provider_with_url("custom:ftp://example.com", None, None);
assert!(result.is_err(), "ftp scheme should be rejected");
let err_msg = result.err().unwrap().to_string();
assert!(
err_msg.contains("http://") || err_msg.contains("https://"),
"error should mention valid schemes: {err_msg}"
);
}
#[test]
fn factory_custom_empty_url_rejected() {
let result = create_provider_with_url("custom:", None, None);
assert!(result.is_err(), "empty custom URL should be rejected");
}
#[test]
fn factory_unknown_provider_rejected() {
let result = create_provider_with_url("nonexistent_provider_xyz", None, None);
assert!(result.is_err(), "unknown provider name should be rejected");
}
// ─────────────────────────────────────────────────────────────────────────────
// OpenAiCompatibleProvider: credential and auth style wiring
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn compatible_provider_bearer_auth_style() {
// Construction with Bearer auth should succeed
let _provider = OpenAiCompatibleProvider::new(
"TestProvider",
"https://api.test.com",
Some("sk-test-key-12345"),
AuthStyle::Bearer,
);
}
#[test]
fn compatible_provider_xapikey_auth_style() {
// Construction with XApiKey auth should succeed
let _provider = OpenAiCompatibleProvider::new(
"TestProvider",
"https://api.test.com",
Some("sk-test-key-12345"),
AuthStyle::XApiKey,
);
}
#[test]
fn compatible_provider_custom_auth_header() {
// Construction with Custom auth should succeed
let _provider = OpenAiCompatibleProvider::new(
"TestProvider",
"https://api.test.com",
Some("sk-test-key-12345"),
AuthStyle::Custom("X-Custom-Auth".into()),
);
}
#[test]
fn compatible_provider_no_credential() {
// Construction without credential should succeed (for local providers)
let _provider = OpenAiCompatibleProvider::new(
"TestLocal",
"http://localhost:11434",
None,
AuthStyle::Bearer,
);
}
#[test]
fn compatible_provider_base_url_trailing_slash_normalized() {
// Construction with trailing slash URL should succeed
let _provider = OpenAiCompatibleProvider::new(
"TestProvider",
"https://api.test.com/v1/",
Some("key"),
AuthStyle::Bearer,
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Provider with api_url override (simulates #721 - Ollama api_url config)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_ollama_with_custom_api_url() {
assert_provider_ok("ollama", None, Some("http://192.168.1.100:11434"));
}
#[test]
fn factory_openai_with_custom_api_url() {
assert_provider_ok(
"openai",
Some("test-key"),
Some("https://custom-openai-proxy.example.com/v1"),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Provider default convenience factory
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn convenience_factory_resolves_major_providers() {
for provider_name in &[
"openai",
"anthropic",
"deepseek",
"mistral",
"groq",
"xai",
"together",
"fireworks",
"perplexity",
] {
let result = create_provider(provider_name, Some("test-key"));
assert!(
result.is_ok(),
"convenience factory should resolve {provider_name}: {}",
result.err().map(|e| e.to_string()).unwrap_or_default()
);
}
}
#[test]
fn convenience_factory_ollama_no_key() {
let result = create_provider("ollama", None);
assert!(
result.is_ok(),
"ollama should not require api key: {}",
result.err().map(|e| e.to_string()).unwrap_or_default()
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Primary providers with custom implementations
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_resolves_openrouter_provider() {
assert_provider_ok("openrouter", Some("test-key"), None);
}
#[test]
fn factory_resolves_gemini_provider() {
assert_provider_ok("gemini", Some("test-key"), None);
}
#[test]
fn factory_resolves_bedrock_provider() {
assert_provider_ok("bedrock", None, None);
}
#[test]
fn factory_resolves_copilot_provider() {
assert_provider_ok("copilot", Some("test-key"), None);
}
#[test]
fn factory_resolves_synthetic_provider() {
assert_provider_ok("synthetic", Some("test-key"), None);
}
#[test]
fn factory_resolves_openai_codex_provider() {
let options = zeroclaw::providers::ProviderRuntimeOptions::default();
let result = create_provider_with_options("openai-codex", None, &options);
assert!(
result.is_ok(),
"openai-codex provider should resolve: {}",
result.err().map(|e| e.to_string()).unwrap_or_default()
);
}
// ─────────────────────────────────────────────────────────────────────────────
// OpenAI-compatible ecosystem providers
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_resolves_venice_provider() {
assert_provider_ok("venice", Some("test-key"), None);
}
#[test]
fn factory_resolves_cohere_provider() {
assert_provider_ok("cohere", Some("test-key"), None);
}
#[test]
fn factory_resolves_opencode_provider() {
assert_provider_ok("opencode", Some("test-key"), None);
}
#[test]
fn factory_resolves_opencode_go_provider() {
assert_provider_ok("opencode-go", Some("test-key"), None);
}
#[test]
fn factory_resolves_astrai_provider() {
assert_provider_ok("astrai", Some("test-key"), None);
}
// ─────────────────────────────────────────────────────────────────────────────
// China region providers
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_resolves_moonshot_provider() {
assert_provider_ok("moonshot", Some("test-key"), None);
}
#[test]
fn factory_resolves_glm_provider() {
assert_provider_ok("glm", Some("test-key"), None);
}
#[test]
fn factory_resolves_qwen_provider() {
assert_provider_ok("qwen", Some("test-key"), None);
}
#[test]
fn factory_resolves_doubao_provider() {
assert_provider_ok("doubao", Some("test-key"), None);
}
#[test]
fn factory_resolves_qianfan_provider() {
assert_provider_ok("qianfan", Some("test-key"), None);
}
#[test]
fn factory_resolves_minimax_provider() {
assert_provider_ok("minimax", Some("test-key"), None);
}
#[test]
fn factory_resolves_kimi_code_provider() {
assert_provider_ok("kimi-code", Some("test-key"), None);
}
#[test]
fn factory_resolves_zai_provider() {
assert_provider_ok("zai", Some("test-key"), None);
}
// ─────────────────────────────────────────────────────────────────────────────
// Local/self-hosted providers
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_resolves_lmstudio_provider() {
assert_provider_ok("lmstudio", None, None);
}
#[test]
fn factory_resolves_llamacpp_provider() {
assert_provider_ok("llamacpp", None, None);
}
#[test]
fn factory_resolves_vllm_provider() {
assert_provider_ok("vllm", None, None);
}
// ─────────────────────────────────────────────────────────────────────────────
// Cloud AI endpoints
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_resolves_vercel_provider() {
assert_provider_ok("vercel", Some("test-key"), None);
}
#[test]
fn factory_resolves_cloudflare_provider() {
assert_provider_ok("cloudflare", Some("test-key"), None);
}
#[test]
fn factory_resolves_nvidia_provider() {
assert_provider_ok("nvidia", Some("test-key"), None);
}
#[test]
fn factory_resolves_ovhcloud_provider() {
assert_provider_ok("ovhcloud", Some("test-key"), None);
}
// ─────────────────────────────────────────────────────────────────────────────
// Alias resolution tests
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_google_alias_resolves_to_gemini() {
assert_provider_ok("google", Some("test-key"), None);
}
#[test]
fn factory_google_gemini_alias_resolves_to_gemini() {
assert_provider_ok("google-gemini", Some("test-key"), None);
}
#[test]
fn factory_aws_bedrock_alias_resolves_to_bedrock() {
assert_provider_ok("aws-bedrock", None, None);
}
#[test]
fn factory_github_copilot_alias_resolves_to_copilot() {
assert_provider_ok("github-copilot", Some("test-key"), None);
}
#[test]
fn factory_vercel_ai_alias_resolves_to_vercel() {
assert_provider_ok("vercel-ai", Some("test-key"), None);
}
#[test]
fn factory_cloudflare_ai_alias_resolves_to_cloudflare() {
assert_provider_ok("cloudflare-ai", Some("test-key"), None);
}
#[test]
fn factory_opencode_zen_alias_resolves_to_opencode() {
assert_provider_ok("opencode-zen", Some("test-key"), None);
}
#[test]
fn factory_lm_studio_alias_resolves_to_lmstudio() {
assert_provider_ok("lm-studio", None, None);
}
#[test]
fn factory_llama_cpp_alias_resolves_to_llamacpp() {
assert_provider_ok("llama.cpp", None, None);
}
#[test]
fn factory_nvidia_nim_alias_resolves_to_nvidia() {
assert_provider_ok("nvidia-nim", Some("test-key"), None);
}
#[test]
fn factory_build_nvidia_com_alias_resolves_to_nvidia() {
assert_provider_ok("build.nvidia.com", Some("test-key"), None);
}
#[test]
fn factory_ovh_alias_resolves_to_ovhcloud() {
assert_provider_ok("ovh", Some("test-key"), None);
}
// ─────────────────────────────────────────────────────────────────────────────
// Custom endpoint tests
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn factory_anthropic_custom_endpoint_resolves() {
assert_provider_ok(
"anthropic-custom:https://api.example.com",
Some("test-key"),
None,
);
}

View File

@@ -0,0 +1,327 @@
//! TG7: Provider Schema Conformance Tests
//!
//! Prevents: Pattern 7 — External schema compatibility bugs (7% of user bugs).
//! Issues: #769, #843
//!
//! Tests request/response serialization to verify required fields are present
//! for each provider's API specification. Validates ChatMessage, ChatResponse,
//! ToolCall, and AuthStyle serialization contracts.
use zeroclaw::providers::compatible::AuthStyle;
use zeroclaw::providers::traits::{ChatMessage, ChatResponse, ToolCall};
// ─────────────────────────────────────────────────────────────────────────────
// ChatMessage serialization
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn chat_message_system_role_correct() {
let msg = ChatMessage::system("You are a helpful assistant");
assert_eq!(msg.role, "system");
assert_eq!(msg.content, "You are a helpful assistant");
}
#[test]
fn chat_message_user_role_correct() {
let msg = ChatMessage::user("Hello");
assert_eq!(msg.role, "user");
assert_eq!(msg.content, "Hello");
}
#[test]
fn chat_message_assistant_role_correct() {
let msg = ChatMessage::assistant("Hi there!");
assert_eq!(msg.role, "assistant");
assert_eq!(msg.content, "Hi there!");
}
#[test]
fn chat_message_tool_role_correct() {
let msg = ChatMessage::tool("tool result");
assert_eq!(msg.role, "tool");
assert_eq!(msg.content, "tool result");
}
#[test]
fn chat_message_serializes_to_json_with_required_fields() {
let msg = ChatMessage::user("test message");
let json = serde_json::to_value(&msg).unwrap();
assert!(json.get("role").is_some(), "JSON must have 'role' field");
assert!(
json.get("content").is_some(),
"JSON must have 'content' field"
);
assert_eq!(json["role"], "user");
assert_eq!(json["content"], "test message");
}
#[test]
fn chat_message_json_roundtrip() {
let original = ChatMessage::assistant("response text");
let json_str = serde_json::to_string(&original).unwrap();
let parsed: ChatMessage = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed.role, original.role);
assert_eq!(parsed.content, original.content);
}
// ─────────────────────────────────────────────────────────────────────────────
// ToolCall serialization (#843 - tool_call_id field)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn tool_call_has_required_fields() {
let tc = ToolCall {
id: "call_abc123".into(),
name: "web_search".into(),
arguments: r#"{"query": "rust programming"}"#.into(),
};
let json = serde_json::to_value(&tc).unwrap();
assert!(json.get("id").is_some(), "ToolCall must have 'id' field");
assert!(
json.get("name").is_some(),
"ToolCall must have 'name' field"
);
assert!(
json.get("arguments").is_some(),
"ToolCall must have 'arguments' field"
);
}
#[test]
fn tool_call_id_preserved_in_serialization() {
let tc = ToolCall {
id: "call_deepseek_42".into(),
name: "shell".into(),
arguments: r#"{"command": "ls"}"#.into(),
};
let json_str = serde_json::to_string(&tc).unwrap();
let parsed: ToolCall = serde_json::from_str(&json_str).unwrap();
assert_eq!(
parsed.id, "call_deepseek_42",
"tool_call_id must survive roundtrip"
);
assert_eq!(parsed.name, "shell");
}
#[test]
fn tool_call_arguments_contain_valid_json() {
let tc = ToolCall {
id: "call_1".into(),
name: "file_write".into(),
arguments: r#"{"path": "/tmp/test.txt", "content": "hello"}"#.into(),
};
// Arguments should parse as valid JSON
let args: serde_json::Value =
serde_json::from_str(&tc.arguments).expect("tool call arguments should be valid JSON");
assert!(args.get("path").is_some());
assert!(args.get("content").is_some());
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool message with tool_call_id (DeepSeek requirement)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn tool_response_message_can_embed_tool_call_id() {
// DeepSeek requires tool_call_id in tool response messages.
// The tool message content can embed the tool_call_id as JSON.
let tool_response =
ChatMessage::tool(r#"{"tool_call_id": "call_abc123", "content": "search results here"}"#);
let parsed: serde_json::Value = serde_json::from_str(&tool_response.content)
.expect("tool response content should be valid JSON");
assert!(
parsed.get("tool_call_id").is_some(),
"tool response should include tool_call_id for DeepSeek compatibility"
);
assert_eq!(parsed["tool_call_id"], "call_abc123");
}
// ─────────────────────────────────────────────────────────────────────────────
// ChatResponse structure
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn chat_response_text_only() {
let resp = ChatResponse {
text: Some("Hello world".into()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
};
assert_eq!(resp.text_or_empty(), "Hello world");
assert!(!resp.has_tool_calls());
}
#[test]
fn chat_response_with_tool_calls() {
let resp = ChatResponse {
text: Some(String::new()),
tool_calls: vec![ToolCall {
id: "tc_1".into(),
name: "echo".into(),
arguments: "{}".into(),
}],
usage: None,
reasoning_content: None,
};
assert!(resp.has_tool_calls());
assert_eq!(resp.tool_calls.len(), 1);
assert_eq!(resp.tool_calls[0].name, "echo");
}
#[test]
fn chat_response_text_or_empty_handles_none() {
let resp = ChatResponse {
text: None,
tool_calls: vec![],
usage: None,
reasoning_content: None,
};
assert_eq!(resp.text_or_empty(), "");
}
#[test]
fn chat_response_multiple_tool_calls() {
let resp = ChatResponse {
text: None,
tool_calls: vec![
ToolCall {
id: "tc_1".into(),
name: "shell".into(),
arguments: r#"{"command": "ls"}"#.into(),
},
ToolCall {
id: "tc_2".into(),
name: "file_read".into(),
arguments: r#"{"path": "test.txt"}"#.into(),
},
],
usage: None,
reasoning_content: None,
};
assert!(resp.has_tool_calls());
assert_eq!(resp.tool_calls.len(), 2);
// Each tool call should have a distinct id
assert_ne!(resp.tool_calls[0].id, resp.tool_calls[1].id);
}
// ─────────────────────────────────────────────────────────────────────────────
// AuthStyle variants
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn auth_style_bearer_is_constructible() {
let style = AuthStyle::Bearer;
assert!(matches!(style, AuthStyle::Bearer));
}
#[test]
fn auth_style_xapikey_is_constructible() {
let style = AuthStyle::XApiKey;
assert!(matches!(style, AuthStyle::XApiKey));
}
#[test]
fn auth_style_custom_header() {
let style = AuthStyle::Custom("X-Custom-Auth".into());
if let AuthStyle::Custom(header) = style {
assert_eq!(header, "X-Custom-Auth");
} else {
panic!("expected AuthStyle::Custom");
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Provider naming consistency
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn provider_construction_with_different_names() {
use zeroclaw::providers::compatible::OpenAiCompatibleProvider;
// Construction with various names should succeed
let _p1 = OpenAiCompatibleProvider::new(
"DeepSeek",
"https://api.deepseek.com",
Some("test-key"),
AuthStyle::Bearer,
);
let _p2 =
OpenAiCompatibleProvider::new("deepseek", "https://api.test.com", None, AuthStyle::Bearer);
}
#[test]
fn provider_construction_with_different_auth_styles() {
use zeroclaw::providers::compatible::OpenAiCompatibleProvider;
let _bearer = OpenAiCompatibleProvider::new(
"Test",
"https://api.test.com",
Some("key"),
AuthStyle::Bearer,
);
let _xapi = OpenAiCompatibleProvider::new(
"Test",
"https://api.test.com",
Some("key"),
AuthStyle::XApiKey,
);
let _custom = OpenAiCompatibleProvider::new(
"Test",
"https://api.test.com",
Some("key"),
AuthStyle::Custom("X-My-Auth".into()),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Conversation history message ordering
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn chat_messages_maintain_role_sequence() {
let history = [
ChatMessage::system("You are helpful"),
ChatMessage::user("What is Rust?"),
ChatMessage::assistant("Rust is a systems programming language"),
ChatMessage::user("Tell me more"),
ChatMessage::assistant("It emphasizes safety and performance"),
];
assert_eq!(history[0].role, "system");
assert_eq!(history[1].role, "user");
assert_eq!(history[2].role, "assistant");
assert_eq!(history[3].role, "user");
assert_eq!(history[4].role, "assistant");
}
#[test]
fn chat_messages_with_tool_calls_maintain_sequence() {
let history = [
ChatMessage::system("You are helpful"),
ChatMessage::user("Search for Rust"),
ChatMessage::assistant("I'll search for that"),
ChatMessage::tool(r#"{"tool_call_id": "tc_1", "content": "search results"}"#),
ChatMessage::assistant("Based on the search results..."),
];
assert_eq!(history.len(), 5);
assert_eq!(history[3].role, "tool");
assert_eq!(history[4].role, "assistant");
// Verify tool message content is valid JSON with tool_call_id
let tool_content: serde_json::Value = serde_json::from_str(&history[3].content).unwrap();
assert!(tool_content.get("tool_call_id").is_some());
}

View File

@@ -0,0 +1,70 @@
//! Regression guard for ChannelMessage field naming consistency.
//!
//! This test prevents accidental reintroduction of the removed `reply_to` field
//! in Rust source code where `reply_target` must be used.
use std::fs;
use std::path::{Path, PathBuf};
const SCAN_PATHS: &[&str] = &["src"];
const FORBIDDEN_PATTERNS: &[&str] = &[".reply_to", "reply_to:"];
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let entries = fs::read_dir(dir)
.unwrap_or_else(|err| panic!("Failed to read directory {}: {err}", dir.display()));
for entry in entries {
let entry =
entry.unwrap_or_else(|err| panic!("Failed to read entry in {}: {err}", dir.display()));
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
#[test]
fn source_does_not_use_legacy_reply_to_field() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut rust_files = Vec::new();
for relative in SCAN_PATHS {
collect_rs_files(&root.join(relative), &mut rust_files);
}
rust_files.sort();
let mut violations = Vec::new();
for file_path in rust_files {
let content = fs::read_to_string(&file_path).unwrap_or_else(|err| {
panic!("Failed to read source file {}: {err}", file_path.display())
});
for (line_idx, line) in content.lines().enumerate() {
for pattern in FORBIDDEN_PATTERNS {
if line.contains(pattern) {
let rel = file_path
.strip_prefix(root)
.unwrap_or(&file_path)
.display()
.to_string();
violations.push(format!(
"{rel}:{} contains forbidden pattern `{pattern}`: {}",
line_idx + 1,
line.trim()
));
}
}
}
}
assert!(
violations.is_empty(),
"Found legacy `reply_to` field usage:\n{}",
violations.join("\n")
);
}

View File

@@ -0,0 +1,164 @@
//! Security component tests.
//!
//! The `security` module is `pub(crate)` so SecurityPolicy cannot be directly
//! instantiated from integration tests. These tests validate security-related
//! behavior through the public API surface: configuration defaults, autonomy
//! config validation, and credential scrubbing patterns.
use zeroclaw::config::{AutonomyConfig, Config};
// ═════════════════════════════════════════════════════════════════════════════
// Autonomy configuration defaults and validation
// ═════════════════════════════════════════════════════════════════════════════
/// Default autonomy level is "supervised".
#[test]
fn security_default_autonomy_is_supervised() {
let config = AutonomyConfig::default();
assert_eq!(
format!("{:?}", config.level),
"Supervised",
"Default autonomy level should be Supervised"
);
}
/// Default workspace_only is true (restricts file access to workspace).
#[test]
fn security_default_workspace_only() {
let config = AutonomyConfig::default();
assert!(
config.workspace_only,
"Default workspace_only should be true for safety"
);
}
/// Max actions per hour has a reasonable default.
#[test]
fn security_default_max_actions_per_hour() {
let config = AutonomyConfig::default();
assert!(
config.max_actions_per_hour > 0,
"max_actions_per_hour should be positive"
);
assert!(
config.max_actions_per_hour <= 1000,
"max_actions_per_hour should have a reasonable upper bound"
);
}
/// Require approval for medium risk is enabled by default.
#[test]
fn security_default_require_approval_for_medium_risk() {
let config = AutonomyConfig::default();
assert!(
config.require_approval_for_medium_risk,
"Should require approval for medium-risk commands by default"
);
}
/// Block high risk commands is enabled by default.
#[test]
fn security_default_block_high_risk_commands() {
let config = AutonomyConfig::default();
assert!(
config.block_high_risk_commands,
"Should block high-risk commands by default"
);
}
// ═════════════════════════════════════════════════════════════════════════════
// Security configuration
// ═════════════════════════════════════════════════════════════════════════════
/// Secret encryption is enabled by default.
#[test]
fn security_secrets_encryption_default() {
let config = Config::default();
assert!(
config.secrets.encrypt,
"Secret encryption should be enabled by default"
);
}
/// Full config has security sections populated with defaults.
#[test]
fn security_full_config_has_autonomy() {
let config = Config::default();
assert_eq!(
format!("{:?}", config.autonomy.level),
"Supervised",
"Default config autonomy should be Supervised"
);
}
// ═════════════════════════════════════════════════════════════════════════════
// Autonomy level serialization round-trip
// ═════════════════════════════════════════════════════════════════════════════
/// AutonomyConfig serializes and deserializes correctly via TOML.
#[test]
fn security_autonomy_config_toml_roundtrip() {
let original = AutonomyConfig::default();
let toml_str = toml::to_string(&original).expect("Failed to serialize AutonomyConfig");
let deserialized: AutonomyConfig =
toml::from_str(&toml_str).expect("Failed to deserialize AutonomyConfig");
assert_eq!(
format!("{:?}", deserialized.level),
format!("{:?}", original.level),
"Autonomy level should survive TOML round-trip"
);
assert_eq!(
deserialized.workspace_only, original.workspace_only,
"workspace_only should survive TOML round-trip"
);
}
/// ReadOnly autonomy level parses from TOML string (with all required fields).
#[test]
fn security_readonly_autonomy_parses() {
let original = AutonomyConfig::default();
let mut toml_str = toml::to_string(&original).expect("Failed to serialize");
// Override the level to readonly
toml_str = toml_str.replace("level = \"supervised\"", "level = \"readonly\"");
let config: AutonomyConfig = toml::from_str(&toml_str).expect("Failed to parse readonly");
assert_eq!(format!("{:?}", config.level), "ReadOnly");
}
/// Full autonomy level parses from TOML string (with all required fields).
#[test]
fn security_full_autonomy_parses() {
let original = AutonomyConfig::default();
let mut toml_str = toml::to_string(&original).expect("Failed to serialize");
// Override the level to full and workspace_only to false
toml_str = toml_str.replace("level = \"supervised\"", "level = \"full\"");
toml_str = toml_str.replace("workspace_only = true", "workspace_only = false");
let config: AutonomyConfig = toml::from_str(&toml_str).expect("Failed to parse full");
assert_eq!(format!("{:?}", config.level), "Full");
assert!(!config.workspace_only);
}
// ═════════════════════════════════════════════════════════════════════════════
// Credential pattern validation (via config/schema)
// ═════════════════════════════════════════════════════════════════════════════
/// Config does not expose raw API keys in Debug output.
#[test]
fn security_config_debug_does_not_leak_api_key() {
let config = Config {
api_key: Some("sk-1234567890abcdef".to_string()),
..Config::default()
};
// The Config struct should either not include api_key in Debug
// or it should be masked. Check that raw key doesn't appear in debug output.
let debug_output = format!("{:?}", config);
// If the full key appears in debug output, flag it.
// Note: some configs may legitimately show partial keys — that's acceptable.
// What matters is the full key isn't exposed in casual logging.
if debug_output.contains("sk-1234567890abcdef") {
// This is a known pattern — Config derives Debug which shows all fields.
// Document it as an area for improvement but don't fail the test,
// since the security boundary is at the scrub_credentials level in loop_.rs.
}
}

View File

@@ -0,0 +1,133 @@
//! Integration tests for WhatsApp webhook signature verification.
//!
//! These tests validate that:
//! 1. Webhooks with valid signatures are accepted
//! 2. Webhooks with invalid signatures are rejected
//! 3. Webhooks with missing signatures are rejected
//! 4. Webhooks are rejected even if JSON is valid but signature is bad
use hmac::{Hmac, Mac};
use sha2::Sha256;
/// Compute valid HMAC-SHA256 signature for a webhook payload
fn compute_signature(app_secret: &str, body: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(app_secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
format!("sha256={}", hex::encode(result.into_bytes()))
}
#[test]
fn whatsapp_signature_rejects_missing_sha256_prefix() {
let secret = "test_app_secret";
let body = b"test payload";
let bad_sig = "abc123"; // Missing sha256= prefix
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
secret, body, bad_sig
));
}
#[test]
fn whatsapp_signature_rejects_invalid_hex() {
let secret = "test_app_secret";
let body = b"test payload";
let bad_sig = "sha256=not-valid-hex!!";
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
secret, body, bad_sig
));
}
#[test]
fn whatsapp_signature_rejects_wrong_signature() {
let secret = "test_app_secret";
let body = b"test payload";
let bad_sig = "sha256=00112233445566778899aabbccddeeff";
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
secret, body, bad_sig
));
}
#[test]
fn whatsapp_signature_accepts_valid_signature() {
let secret = "test_app_secret";
let body = b"test payload";
let valid_sig = compute_signature(secret, body);
assert!(zeroclaw::gateway::verify_whatsapp_signature(
secret, body, &valid_sig
));
}
#[test]
fn whatsapp_signature_rejects_tampered_body() {
let secret = "test_app_secret";
let original_body = b"original message";
let tampered_body = b"tampered message";
// Compute signature for original body
let sig = compute_signature(secret, original_body);
// Tampered body should be rejected even with valid-looking signature
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
secret,
tampered_body,
&sig
));
}
#[test]
fn whatsapp_signature_rejects_wrong_secret() {
let correct_secret = "correct_secret";
let wrong_secret = "wrong_secret";
let body = b"test payload";
// Compute signature with correct secret
let sig = compute_signature(correct_secret, body);
// Wrong secret should reject the signature
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
wrong_secret,
body,
&sig
));
}
#[test]
fn whatsapp_signature_rejects_empty_signature() {
let secret = "test_app_secret";
let body = b"test payload";
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
secret, body, ""
));
}
#[test]
fn whatsapp_signature_different_secrets_produce_different_sigs() {
let secret1 = "secret_one";
let secret2 = "secret_two";
let body = b"same payload";
let sig1 = compute_signature(secret1, body);
let sig2 = compute_signature(secret2, body);
// Different secrets should produce different signatures
assert_ne!(sig1, sig2);
// Each signature should only verify with its own secret
assert!(zeroclaw::gateway::verify_whatsapp_signature(
secret1, body, &sig1
));
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
secret2, body, &sig1
));
assert!(zeroclaw::gateway::verify_whatsapp_signature(
secret2, body, &sig2
));
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
secret1, body, &sig2
));
}

Binary file not shown.

View File

@@ -0,0 +1,22 @@
%PDF-1.4
%âãÏÓ
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 5 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
5 0 obj<</Length 41>>stream
BT /F1 24 Tf 100 700 Td (Hello PDF) Tj ET
endstream
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000058 00000 n
0000000107 00000 n
0000000217 00000 n
0000000278 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
365
%%EOF

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

View File

@@ -0,0 +1,66 @@
{
"model_name": "test-multi-tool-chain",
"turns": [
{
"user_input": "Echo three messages in sequence",
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_1",
"name": "echo",
"arguments": {"message": "first"}
}
],
"input_tokens": 30,
"output_tokens": 15
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_2",
"name": "echo",
"arguments": {"message": "second"}
}
],
"input_tokens": 60,
"output_tokens": 15
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_3",
"name": "echo",
"arguments": {"message": "third"}
}
],
"input_tokens": 90,
"output_tokens": 15
}
},
{
"response": {
"type": "text",
"content": "I echoed three messages: first, second, and third.",
"input_tokens": 120,
"output_tokens": 20
}
}
]
}
],
"expects": {
"response_contains": ["first", "second", "third"],
"tools_used": ["echo"],
"max_tool_calls": 3,
"all_tools_succeeded": true
}
}

View File

@@ -0,0 +1,38 @@
{
"model_name": "test-single-tool-echo",
"turns": [
{
"user_input": "Echo hello for me",
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_1",
"name": "echo",
"arguments": {"message": "hello"}
}
],
"input_tokens": 30,
"output_tokens": 15
}
},
{
"response": {
"type": "text",
"content": "The echo tool said: hello",
"input_tokens": 50,
"output_tokens": 10
}
}
]
}
],
"expects": {
"response_contains": ["hello"],
"tools_used": ["echo"],
"max_tool_calls": 1,
"all_tools_succeeded": true
}
}

View File

@@ -0,0 +1,24 @@
{
"model_name": "test-smoke-greeting",
"turns": [
{
"user_input": "Hello, how are you?",
"steps": [
{
"response": {
"type": "text",
"content": "Hello! I'm doing well, thank you for asking. How can I help you today?",
"input_tokens": 20,
"output_tokens": 15
}
}
]
}
],
"expects": {
"response_contains": ["Hello"],
"response_not_contains": ["error", "ERROR"],
"tools_used": [],
"max_tool_calls": 0
}
}

View File

@@ -0,0 +1,378 @@
//! End-to-end integration tests for agent orchestration.
//!
//! These tests exercise the full agent turn cycle through the public API,
//! using mock providers and tools to validate orchestration behavior without
//! external service dependencies. They complement the unit tests in
//! `src/agent/tests.rs` by running at the integration test boundary.
//!
//! Ref: https://github.com/zeroclaw-labs/zeroclaw/issues/618 (item 6)
use crate::support::helpers::{
build_agent, build_agent_xml, build_recording_agent, text_response, tool_response,
StaticMemoryLoader,
};
use crate::support::{CountingTool, EchoTool, MockProvider, RecordingProvider};
use zeroclaw::providers::traits::ChatMessage;
use zeroclaw::providers::{ChatResponse, ConversationMessage, ToolCall};
// ═════════════════════════════════════════════════════════════════════════════
// E2E smoke tests — full agent turn cycle
// ═════════════════════════════════════════════════════════════════════════════
/// Validates the simplest happy path: user message → LLM text response.
#[tokio::test]
async fn e2e_simple_text_response() {
let provider = Box::new(MockProvider::new(vec![text_response(
"Hello from mock provider",
)]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("hi").await.unwrap();
assert!(!response.is_empty(), "Expected non-empty text response");
}
/// Validates single tool call → tool execution → final LLM response.
#[tokio::test]
async fn e2e_single_tool_call_cycle() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: r#"{"message": "hello from tool"}"#.into(),
}]),
text_response("Tool executed successfully"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("run echo").await.unwrap();
assert!(
!response.is_empty(),
"Expected non-empty response after tool execution"
);
}
/// Validates multi-step tool chain: tool A → tool B → tool C → final response.
#[tokio::test]
async fn e2e_multi_step_tool_chain() {
let (counting_tool, count) = CountingTool::new();
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "counter".into(),
arguments: "{}".into(),
}]),
tool_response(vec![ToolCall {
id: "tc2".into(),
name: "counter".into(),
arguments: "{}".into(),
}]),
text_response("Done after 2 tool calls"),
]));
let mut agent = build_agent(provider, vec![Box::new(counting_tool)]);
let response = agent.turn("count twice").await.unwrap();
assert!(
!response.is_empty(),
"Expected non-empty response after tool chain"
);
assert_eq!(*count.lock().unwrap(), 2);
}
/// Validates that the XML dispatcher path also works end-to-end.
#[tokio::test]
async fn e2e_xml_dispatcher_tool_call() {
let provider = Box::new(MockProvider::new(vec![
ChatResponse {
text: Some(
r#"<tool_call>
{"name": "echo", "arguments": {"message": "xml dispatch"}}
</tool_call>"#
.into(),
),
tool_calls: vec![],
usage: None,
reasoning_content: None,
},
text_response("XML tool executed"),
]));
let mut agent = build_agent_xml(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("test xml dispatch").await.unwrap();
assert!(
!response.is_empty(),
"Expected non-empty response from XML dispatcher"
);
}
/// Validates that multiple sequential turns maintain conversation coherence.
#[tokio::test]
async fn e2e_multi_turn_conversation() {
let provider = Box::new(MockProvider::new(vec![
text_response("First response"),
text_response("Second response"),
text_response("Third response"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let r1 = agent.turn("turn 1").await.unwrap();
assert!(!r1.is_empty(), "Expected non-empty first response");
let r2 = agent.turn("turn 2").await.unwrap();
assert!(!r2.is_empty(), "Expected non-empty second response");
assert_ne!(r1, r2, "Sequential turn responses should be distinct");
let r3 = agent.turn("turn 3").await.unwrap();
assert!(!r3.is_empty(), "Expected non-empty third response");
assert_ne!(r2, r3, "Sequential turn responses should be distinct");
}
/// Validates that the agent handles unknown tool names gracefully.
#[tokio::test]
async fn e2e_unknown_tool_recovery() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "nonexistent_tool".into(),
arguments: "{}".into(),
}]),
text_response("Recovered from unknown tool"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("call missing tool").await.unwrap();
assert!(
!response.is_empty(),
"Expected non-empty response after unknown tool recovery"
);
}
/// Validates parallel tool dispatch in a single response.
#[tokio::test]
async fn e2e_parallel_tool_dispatch() {
let (counting_tool, count) = CountingTool::new();
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![
ToolCall {
id: "tc1".into(),
name: "counter".into(),
arguments: "{}".into(),
},
ToolCall {
id: "tc2".into(),
name: "counter".into(),
arguments: "{}".into(),
},
]),
text_response("Both tools ran"),
]));
let mut agent = build_agent(provider, vec![Box::new(counting_tool)]);
let response = agent.turn("run both").await.unwrap();
assert!(
!response.is_empty(),
"Expected non-empty response after parallel dispatch"
);
assert_eq!(*count.lock().unwrap(), 2);
}
// ═════════════════════════════════════════════════════════════════════════════
// Multi-turn history fidelity & memory enrichment tests
// ═════════════════════════════════════════════════════════════════════════════
/// Validates that multi-turn conversation correctly accumulates history
/// and passes growing message sequences to the provider on each turn.
#[tokio::test]
async fn e2e_multi_turn_history_fidelity() {
let (provider, recorded) = RecordingProvider::new(vec![
text_response("response 1"),
text_response("response 2"),
text_response("response 3"),
]);
let mut agent = build_recording_agent(Box::new(provider), vec![], None);
let r1 = agent.turn("msg 1").await.unwrap();
assert_eq!(r1, "response 1");
let r2 = agent.turn("msg 2").await.unwrap();
assert_eq!(r2, "response 2");
let r3 = agent.turn("msg 3").await.unwrap();
assert_eq!(r3, "response 3");
let requests = recorded.lock().unwrap();
assert_eq!(requests.len(), 3, "Provider should receive 3 requests");
// Request 1: system + user("msg 1")
let req1 = &requests[0];
assert!(req1.len() >= 2);
assert_eq!(req1[0].role, "system");
assert_eq!(req1[1].role, "user");
assert!(req1[1].content.contains("msg 1"));
// Request 2: system + user("msg 1") + assistant("response 1") + user("msg 2")
let req2 = &requests[1];
let req2_users: Vec<&ChatMessage> = req2.iter().filter(|m| m.role == "user").collect();
let req2_assts: Vec<&ChatMessage> = req2.iter().filter(|m| m.role == "assistant").collect();
assert_eq!(req2_users.len(), 2, "Request 2: expected 2 user messages");
assert_eq!(
req2_assts.len(),
1,
"Request 2: expected 1 assistant message"
);
assert!(req2_users[0].content.contains("msg 1"));
assert!(req2_users[1].content.contains("msg 2"));
assert_eq!(req2_assts[0].content, "response 1");
// Request 3: full history — 3 user + 2 assistant messages
let req3 = &requests[2];
let req3_users: Vec<&ChatMessage> = req3.iter().filter(|m| m.role == "user").collect();
let req3_assts: Vec<&ChatMessage> = req3.iter().filter(|m| m.role == "assistant").collect();
assert_eq!(req3_users.len(), 3, "Request 3: expected 3 user messages");
assert_eq!(
req3_assts.len(),
2,
"Request 3: expected 2 assistant messages"
);
assert!(req3_users[0].content.contains("msg 1"));
assert!(req3_users[1].content.contains("msg 2"));
assert!(req3_users[2].content.contains("msg 3"));
assert_eq!(req3_assts[0].content, "response 1");
assert_eq!(req3_assts[1].content, "response 2");
// Verify agent history: system + 3*(user + assistant) = 7
let history = agent.history();
assert_eq!(history.len(), 7);
assert!(matches!(&history[0], ConversationMessage::Chat(c) if c.role == "system"));
assert!(matches!(&history[1], ConversationMessage::Chat(c) if c.role == "user"));
assert!(matches!(&history[2], ConversationMessage::Chat(c) if c.role == "assistant"));
assert!(
matches!(&history[6], ConversationMessage::Chat(c) if c.role == "assistant" && c.content == "response 3")
);
}
/// Validates that a custom MemoryLoader injects RAG context into user
/// messages before they reach the provider.
#[tokio::test]
async fn e2e_memory_enrichment_injects_context() {
let (provider, recorded) = RecordingProvider::new(vec![text_response("enriched response")]);
let memory_context = "[Memory context]\n- user_name: test_user\n[/Memory context]\n\n";
let loader = StaticMemoryLoader::new(memory_context);
let mut agent = build_recording_agent(Box::new(provider), vec![], Some(Box::new(loader)));
let response = agent.turn("hello").await.unwrap();
assert_eq!(response, "enriched response");
// Provider received enriched message
let requests = recorded.lock().unwrap();
assert_eq!(requests.len(), 1);
let user_msg = requests[0].iter().find(|m| m.role == "user").unwrap();
assert!(
user_msg.content.contains("[Memory context]"),
"User message should contain memory context, got: {}",
user_msg.content,
);
assert!(
user_msg.content.contains("user_name: test_user"),
"User message should contain memory key-value pair",
);
assert!(
user_msg.content.ends_with("hello"),
"User message should end with original text, got: {}",
user_msg.content,
);
// Agent history also stores enriched message
let history = agent.history();
match &history[1] {
ConversationMessage::Chat(c) => {
assert_eq!(c.role, "user");
assert!(c.content.contains("[Memory context]"));
assert!(c.content.ends_with("hello"));
}
other => panic!("Expected Chat variant for user message, got: {other:?}"),
}
}
/// Validates multi-turn conversation with memory enrichment: every user
/// message is enriched, and the provider sees the full enriched history.
#[tokio::test]
async fn e2e_multi_turn_with_memory_enrichment() {
let (provider, recorded) =
RecordingProvider::new(vec![text_response("answer 1"), text_response("answer 2")]);
let memory_context = "[Memory context]\n- project: zeroclaw\n[/Memory context]\n\n";
let loader = StaticMemoryLoader::new(memory_context);
let mut agent = build_recording_agent(Box::new(provider), vec![], Some(Box::new(loader)));
let r1 = agent.turn("first question").await.unwrap();
assert_eq!(r1, "answer 1");
let r2 = agent.turn("second question").await.unwrap();
assert_eq!(r2, "answer 2");
let requests = recorded.lock().unwrap();
assert_eq!(requests.len(), 2);
// Turn 1: user message is enriched
let req1_user = requests[0].iter().find(|m| m.role == "user").unwrap();
assert!(req1_user.content.contains("[Memory context]"));
assert!(req1_user.content.contains("project: zeroclaw"));
assert!(req1_user.content.ends_with("first question"));
// Turn 2: both user messages enriched, assistant from turn 1 present
let req2_users: Vec<&ChatMessage> = requests[1].iter().filter(|m| m.role == "user").collect();
assert_eq!(req2_users.len(), 2, "Request 2 should have 2 user messages");
// Turn 1 user message still enriched in history
assert!(req2_users[0].content.contains("[Memory context]"));
assert!(req2_users[0].content.ends_with("first question"));
// Turn 2 user message also enriched
assert!(req2_users[1].content.contains("[Memory context]"));
assert!(req2_users[1].content.ends_with("second question"));
// Assistant response from turn 1 preserved
let req2_assts: Vec<&ChatMessage> = requests[1]
.iter()
.filter(|m| m.role == "assistant")
.collect();
assert_eq!(req2_assts.len(), 1);
assert_eq!(req2_assts[0].content, "answer 1");
// History: system + 2*(enriched_user + assistant) = 5
assert_eq!(agent.history().len(), 5);
}
/// Validates that empty memory context does not prepend memory text.
/// A per-turn datetime prefix may still be present.
#[tokio::test]
async fn e2e_empty_memory_context_passthrough() {
let (provider, recorded) = RecordingProvider::new(vec![text_response("plain response")]);
let loader = StaticMemoryLoader::new("");
let mut agent = build_recording_agent(Box::new(provider), vec![], Some(Box::new(loader)));
let response = agent.turn("hello").await.unwrap();
assert_eq!(response, "plain response");
let requests = recorded.lock().unwrap();
let user_msg = requests[0].iter().find(|m| m.role == "user").unwrap();
assert!(
user_msg.content.ends_with("hello"),
"User payload should preserve original text suffix, got: {}",
user_msg.content
);
assert!(
!user_msg.content.contains("[Memory context]"),
"Empty context should not prepend memory context text, got: {}",
user_msg.content
);
}

View File

@@ -0,0 +1,254 @@
//! TG4: Agent Loop Robustness Tests
//!
//! Prevents: Pattern 4 — Agent loop & tool call processing bugs (13% of user bugs).
//! Issues: #746, #418, #777, #848
//!
//! Tests agent behavior with malformed tool calls, empty responses,
//! max iteration limits, and cascading tool failures using mock providers.
//! Complements inline parse_tool_calls tests in `src/agent/loop_.rs`.
use crate::support::helpers::{build_agent, text_response, tool_response};
use crate::support::{CountingTool, EchoTool, FailingTool, MockProvider};
use zeroclaw::providers::{ChatResponse, ToolCall};
// ═════════════════════════════════════════════════════════════════════════════
// TG4.1: Malformed tool call recovery
// ═════════════════════════════════════════════════════════════════════════════
/// Agent should recover when LLM returns text with residual XML tags (#746)
#[tokio::test]
async fn agent_recovers_from_text_with_xml_residue() {
let provider = Box::new(MockProvider::new(vec![text_response(
"Here is the result. Some leftover </tool_call> text after.",
)]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("test").await.unwrap();
assert!(
!response.is_empty(),
"agent should produce non-empty response despite XML residue"
);
}
/// Agent should handle tool call with empty arguments gracefully
#[tokio::test]
async fn agent_handles_tool_call_with_empty_arguments() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: "{}".into(),
}]),
text_response("Tool with empty args executed"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("call with empty args").await.unwrap();
assert!(!response.is_empty());
}
/// Agent should handle unknown tool name without crashing (#848 related)
#[tokio::test]
async fn agent_handles_nonexistent_tool_gracefully() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "absolutely_nonexistent_tool".into(),
arguments: "{}".into(),
}]),
text_response("Recovered from unknown tool"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("call missing tool").await.unwrap();
assert!(
!response.is_empty(),
"agent should recover from unknown tool"
);
}
// ═════════════════════════════════════════════════════════════════════════════
// TG4.2: Tool failure cascade handling (#848)
// ═════════════════════════════════════════════════════════════════════════════
/// Agent should handle repeated tool failures without infinite loop
#[tokio::test]
async fn agent_handles_failing_tool() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "failing_tool".into(),
arguments: "{}".into(),
}]),
text_response("Tool failed but I recovered"),
]));
let mut agent = build_agent(provider, vec![Box::new(FailingTool)]);
let response = agent.turn("use failing tool").await.unwrap();
assert!(
!response.is_empty(),
"agent should produce response even after tool failure"
);
}
/// Agent should handle mixed tool calls (some succeed, some fail)
#[tokio::test]
async fn agent_handles_mixed_tool_success_and_failure() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![
ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: r#"{"message": "success"}"#.into(),
},
ToolCall {
id: "tc2".into(),
name: "failing_tool".into(),
arguments: "{}".into(),
},
]),
text_response("Mixed results processed"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool), Box::new(FailingTool)]);
let response = agent.turn("mixed tools").await.unwrap();
assert!(!response.is_empty());
}
// ═════════════════════════════════════════════════════════════════════════════
// TG4.3: Iteration limit enforcement (#777)
// ═════════════════════════════════════════════════════════════════════════════
/// Agent should not exceed max_tool_iterations (default=10) even with
/// a provider that keeps returning tool calls
#[tokio::test]
async fn agent_respects_max_tool_iterations() {
let (counting_tool, count) = CountingTool::new();
// Create 20 tool call responses - more than the default limit of 10
let mut responses: Vec<ChatResponse> = (0..20)
.map(|i| {
tool_response(vec![ToolCall {
id: format!("tc_{i}"),
name: "counter".into(),
arguments: "{}".into(),
}])
})
.collect();
// Add a final text response that would be used if limit is reached
responses.push(text_response("Final response after iterations"));
let provider = Box::new(MockProvider::new(responses));
let mut agent = build_agent(provider, vec![Box::new(counting_tool)]);
// Agent should complete (either by hitting iteration limit or running out of responses)
let result = agent.turn("keep calling tools").await;
// The agent should complete without hanging
assert!(result.is_ok() || result.is_err());
let invocations = *count.lock().unwrap();
assert!(
invocations <= 10,
"tool invocations ({invocations}) should not exceed default max_tool_iterations (10)"
);
}
// ═════════════════════════════════════════════════════════════════════════════
// TG4.4: Empty and whitespace responses
// ═════════════════════════════════════════════════════════════════════════════
/// Agent should handle empty text response from provider (#418 related)
#[tokio::test]
async fn agent_handles_empty_provider_response() {
let provider = Box::new(MockProvider::new(vec![ChatResponse {
text: Some(String::new()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
// Should not panic
let _result = agent.turn("test").await;
}
/// Agent should handle None text response from provider
#[tokio::test]
async fn agent_handles_none_text_response() {
let provider = Box::new(MockProvider::new(vec![ChatResponse {
text: None,
tool_calls: vec![],
usage: None,
reasoning_content: None,
}]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let _result = agent.turn("test").await;
}
/// Agent should handle whitespace-only response
#[tokio::test]
async fn agent_handles_whitespace_only_response() {
let provider = Box::new(MockProvider::new(vec![text_response(" \n\t ")]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let _result = agent.turn("test").await;
}
// ═════════════════════════════════════════════════════════════════════════════
// TG4.5: Tool call with special content
// ═════════════════════════════════════════════════════════════════════════════
/// Agent should handle tool arguments with unicode content
#[tokio::test]
async fn agent_handles_unicode_tool_arguments() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: r#"{"message": " 🌍"}"#.into(),
}]),
text_response("Unicode tool executed"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("unicode test").await.unwrap();
assert!(!response.is_empty());
}
/// Agent should handle tool arguments with nested JSON
#[tokio::test]
async fn agent_handles_nested_json_tool_arguments() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: r#"{"message": "{\"nested\": true, \"deep\": {\"level\": 3}}"}"#.into(),
}]),
text_response("Nested JSON tool executed"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("nested json test").await.unwrap();
assert!(!response.is_empty());
}
/// Agent should handle tool call followed by immediate text (no second LLM call)
#[tokio::test]
async fn agent_handles_sequential_tool_then_text() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: r#"{"message": "step 1"}"#.into(),
}]),
text_response("Final answer after tool"),
]));
let mut agent = build_agent(provider, vec![Box::new(EchoTool)]);
let response = agent.turn("two step").await.unwrap();
assert!(
!response.is_empty(),
"should produce final text after tool execution"
);
}

View File

@@ -0,0 +1,310 @@
use tempfile::TempDir;
use zeroclaw::config::schema::{CronJobDecl, CronScheduleDecl};
use zeroclaw::config::Config;
use zeroclaw::cron::{get_job, list_jobs, sync_declarative_jobs, JobType, Schedule};
fn test_config(tmp: &TempDir, schedule_cron: Option<String>) -> Config {
let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
config.backup.schedule_cron = schedule_cron;
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config
}
#[test]
fn backup_cron_job_synced_when_schedule_set() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
// Synthesize builtin backup job from config.backup.schedule_cron
let mut jobs_with_builtin = config.cron.jobs.clone();
if let Some(schedule_cron) = &config.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_with_builtin.push(backup_job);
}
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
let job = get_job(&config, "__builtin_backup").unwrap();
assert_eq!(job.id, "__builtin_backup");
assert_eq!(job.command, "backup create");
assert_eq!(job.source, "declarative");
assert!(matches!(job.schedule, Schedule::Cron { ref expr, .. } if expr == "0 3 * * *"));
}
#[test]
fn backup_cron_job_not_synced_when_schedule_none() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp, None);
// No builtin backup job should be synthesized
let jobs_with_builtin = config.cron.jobs.clone();
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
let result = get_job(&config, "__builtin_backup");
assert!(
result.is_err(),
"builtin backup job should not exist when schedule_cron is None"
);
}
#[test]
fn backup_cron_job_removed_when_schedule_cleared() {
let tmp = TempDir::new().unwrap();
let config_with_schedule = test_config(&tmp, Some("0 3 * * *".to_string()));
// First sync: create the builtin backup job
let mut jobs_with_builtin = config_with_schedule.cron.jobs.clone();
if let Some(schedule_cron) = &config_with_schedule.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_with_builtin.push(backup_job);
}
sync_declarative_jobs(&config_with_schedule, &jobs_with_builtin).unwrap();
assert!(get_job(&config_with_schedule, "__builtin_backup").is_ok());
// Second sync: remove schedule_cron from config
let config_without_schedule = test_config(&tmp, None);
let jobs_no_builtin = config_without_schedule.cron.jobs.clone();
sync_declarative_jobs(&config_without_schedule, &jobs_no_builtin).unwrap();
let result = get_job(&config_without_schedule, "__builtin_backup");
assert!(
result.is_err(),
"builtin backup job should be removed when schedule_cron is cleared"
);
}
#[test]
fn backup_cron_job_schedule_updated() {
let tmp = TempDir::new().unwrap();
let config_v1 = test_config(&tmp, Some("0 3 * * *".to_string()));
// First sync with schedule "0 3 * * *"
let mut jobs_v1 = config_v1.cron.jobs.clone();
if let Some(schedule_cron) = &config_v1.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_v1.push(backup_job);
}
sync_declarative_jobs(&config_v1, &jobs_v1).unwrap();
let job_v1 = get_job(&config_v1, "__builtin_backup").unwrap();
let next_run_v1 = job_v1.next_run;
// Second sync with schedule "0 2 * * *"
let config_v2 = test_config(&tmp, Some("0 2 * * *".to_string()));
let mut jobs_v2 = config_v2.cron.jobs.clone();
if let Some(schedule_cron) = &config_v2.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_v2.push(backup_job);
}
sync_declarative_jobs(&config_v2, &jobs_v2).unwrap();
let job_v2 = get_job(&config_v2, "__builtin_backup").unwrap();
assert!(matches!(job_v2.schedule, Schedule::Cron { ref expr, .. } if expr == "0 2 * * *"));
assert_ne!(
job_v2.next_run, next_run_v1,
"next_run should be recalculated when schedule changes"
);
}
#[test]
fn backup_cron_job_id_is_stable() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
// Sync twice with same config
for _ in 0..2 {
let mut jobs_with_builtin = config.cron.jobs.clone();
if let Some(schedule_cron) = &config.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_with_builtin.push(backup_job);
}
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
}
// Verify only one job exists with stable ID
let job = get_job(&config, "__builtin_backup").unwrap();
assert_eq!(job.id, "__builtin_backup");
let all_jobs = list_jobs(&config).unwrap();
let backup_jobs: Vec<_> = all_jobs
.iter()
.filter(|j| j.id == "__builtin_backup")
.collect();
assert_eq!(
backup_jobs.len(),
1,
"should have exactly one builtin backup job, not duplicates"
);
}
#[test]
fn backup_cron_job_command_is_backup_create() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
let mut jobs_with_builtin = config.cron.jobs.clone();
if let Some(schedule_cron) = &config.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_with_builtin.push(backup_job);
}
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
let job = get_job(&config, "__builtin_backup").unwrap();
assert_eq!(job.command, "backup create");
}
#[test]
fn backup_cron_job_type_is_shell() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
let mut jobs_with_builtin = config.cron.jobs.clone();
if let Some(schedule_cron) = &config.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_with_builtin.push(backup_job);
}
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
let job = get_job(&config, "__builtin_backup").unwrap();
assert_eq!(job.job_type, JobType::Shell);
}
#[test]
fn backup_cron_job_source_is_declarative() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp, Some("0 3 * * *".to_string()));
let mut jobs_with_builtin = config.cron.jobs.clone();
if let Some(schedule_cron) = &config.backup.schedule_cron {
let backup_job = CronJobDecl {
id: "__builtin_backup".to_string(),
name: Some("Scheduled backup".to_string()),
job_type: "shell".to_string(),
schedule: CronScheduleDecl::Cron {
expr: schedule_cron.clone(),
tz: None,
},
command: Some("backup create".to_string()),
prompt: None,
enabled: true,
model: None,
allowed_tools: None,
session_target: None,
delivery: None,
};
jobs_with_builtin.push(backup_job);
}
sync_declarative_jobs(&config, &jobs_with_builtin).unwrap();
let job = get_job(&config, "__builtin_backup").unwrap();
assert_eq!(job.source, "declarative");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
//! TG3: Channel Message Identity & Routing Tests
//!
//! Prevents: Pattern 3 — Channel message routing & identity bugs (17% of user bugs).
//! Issues: #496, #483, #620, #415, #503
//!
//! Tests that ChannelMessage fields are used consistently and that the
//! SendMessage → Channel trait contract preserves correct identity semantics.
//! Verifies sender/reply_target field contracts to prevent field swaps.
use async_trait::async_trait;
use zeroclaw::channels::traits::{Channel, ChannelMessage, SendMessage};
// ─────────────────────────────────────────────────────────────────────────────
// ChannelMessage construction and field semantics
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn channel_message_sender_field_holds_platform_user_id() {
// Simulates Telegram: sender should be numeric chat_id, not username
let msg = ChannelMessage {
id: "msg_1".into(),
sender: "123456789".into(), // numeric chat_id
reply_target: "msg_0".into(),
content: "test message".into(),
channel: "telegram".into(),
timestamp: 1700000000,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
assert_eq!(msg.sender, "123456789");
// Sender should be the platform-level user/chat identifier
assert!(
msg.sender.chars().all(|c| c.is_ascii_digit()),
"Telegram sender should be numeric chat_id, got: {}",
msg.sender
);
}
#[test]
fn channel_message_reply_target_distinct_from_sender() {
// Simulates Discord: reply_target should be channel_id, not sender user_id
let msg = ChannelMessage {
id: "msg_1".into(),
sender: "user_987654".into(), // Discord user ID
reply_target: "channel_123".into(), // Discord channel ID for replies
content: "test message".into(),
channel: "discord".into(),
timestamp: 1700000000,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
assert_ne!(
msg.sender, msg.reply_target,
"sender and reply_target should be distinct for Discord"
);
assert_eq!(msg.reply_target, "channel_123");
}
#[test]
fn channel_message_fields_not_swapped() {
// Guards against #496 (Telegram) and #483 (Discord) field swap bugs
let msg = ChannelMessage {
id: "msg_42".into(),
sender: "sender_value".into(),
reply_target: "target_value".into(),
content: "payload".into(),
channel: "test".into(),
timestamp: 1700000000,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
assert_eq!(
msg.sender, "sender_value",
"sender field should not be swapped"
);
assert_eq!(
msg.reply_target, "target_value",
"reply_target field should not be swapped"
);
assert_ne!(
msg.sender, msg.reply_target,
"sender and reply_target should remain distinct"
);
}
#[test]
fn channel_message_preserves_all_fields_on_clone() {
let original = ChannelMessage {
id: "clone_test".into(),
sender: "sender_123".into(),
reply_target: "target_456".into(),
content: "cloned content".into(),
channel: "test_channel".into(),
timestamp: 1700000001,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
};
let cloned = original.clone();
assert_eq!(cloned.id, original.id);
assert_eq!(cloned.sender, original.sender);
assert_eq!(cloned.reply_target, original.reply_target);
assert_eq!(cloned.content, original.content);
assert_eq!(cloned.channel, original.channel);
assert_eq!(cloned.timestamp, original.timestamp);
}
// ─────────────────────────────────────────────────────────────────────────────
// SendMessage construction
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn send_message_new_sets_content_and_recipient() {
let msg = SendMessage::new("Hello", "recipient_123");
assert_eq!(msg.content, "Hello");
assert_eq!(msg.recipient, "recipient_123");
assert!(msg.subject.is_none(), "subject should be None by default");
}
#[test]
fn send_message_with_subject_sets_all_fields() {
let msg = SendMessage::with_subject("Hello", "recipient_123", "Re: Test");
assert_eq!(msg.content, "Hello");
assert_eq!(msg.recipient, "recipient_123");
assert_eq!(msg.subject.as_deref(), Some("Re: Test"));
}
#[test]
fn send_message_recipient_carries_platform_target() {
// Verifies that SendMessage::recipient is used as the platform delivery target
// For Telegram: this should be the chat_id
// For Discord: this should be the channel_id
let telegram_msg = SendMessage::new("response", "123456789");
assert_eq!(
telegram_msg.recipient, "123456789",
"Telegram SendMessage recipient should be chat_id"
);
let discord_msg = SendMessage::new("response", "channel_987654");
assert_eq!(
discord_msg.recipient, "channel_987654",
"Discord SendMessage recipient should be channel_id"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Channel trait contract: send/listen roundtrip via DummyChannel
// ─────────────────────────────────────────────────────────────────────────────
/// Test channel that captures sent messages for assertion
struct CapturingChannel {
sent: std::sync::Mutex<Vec<SendMessage>>,
}
impl CapturingChannel {
fn new() -> Self {
Self {
sent: std::sync::Mutex::new(Vec::new()),
}
}
fn sent_messages(&self) -> Vec<SendMessage> {
self.sent.lock().unwrap().clone()
}
}
#[async_trait]
impl Channel for CapturingChannel {
fn name(&self) -> &str {
"capturing"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
self.sent.lock().unwrap().push(message.clone());
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
tx.send(ChannelMessage {
id: "listen_1".into(),
sender: "test_sender".into(),
reply_target: "test_target".into(),
content: "incoming".into(),
channel: "capturing".into(),
timestamp: 1700000000,
thread_ts: None,
interruption_scope_id: None,
attachments: vec![],
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}
#[tokio::test]
async fn channel_send_preserves_recipient() {
let channel = CapturingChannel::new();
let msg = SendMessage::new("Hello", "target_123");
channel.send(&msg).await.unwrap();
let sent = channel.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].recipient, "target_123");
assert_eq!(sent[0].content, "Hello");
}
#[tokio::test]
async fn channel_listen_produces_correct_identity_fields() {
let channel = CapturingChannel::new();
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
channel.listen(tx).await.unwrap();
let received = rx.recv().await.expect("should receive message");
assert_eq!(received.sender, "test_sender");
assert_eq!(received.reply_target, "test_target");
assert_ne!(
received.sender, received.reply_target,
"listen() should populate sender and reply_target distinctly"
);
}
#[tokio::test]
async fn channel_send_reply_uses_sender_from_listen() {
let channel = CapturingChannel::new();
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
// Simulate: listen() → receive message → send reply using sender
channel.listen(tx).await.unwrap();
let incoming = rx.recv().await.expect("should receive message");
// Reply should go to the reply_target, not sender
let reply = SendMessage::new("reply content", &incoming.reply_target);
channel.send(&reply).await.unwrap();
let sent = channel.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(
sent[0].recipient, "test_target",
"reply should use reply_target as recipient"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Channel trait default methods
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn channel_health_check_default_returns_true() {
let channel = CapturingChannel::new();
assert!(
channel.health_check().await,
"default health_check should return true"
);
}
#[tokio::test]
async fn channel_typing_defaults_succeed() {
let channel = CapturingChannel::new();
assert!(channel.start_typing("target").await.is_ok());
assert!(channel.stop_typing("target").await.is_ok());
}
#[tokio::test]
async fn channel_draft_defaults() {
let channel = CapturingChannel::new();
assert!(!channel.supports_draft_updates());
let draft_result = channel
.send_draft(&SendMessage::new("draft", "target"))
.await
.unwrap();
assert!(
draft_result.is_none(),
"default send_draft should return None"
);
assert!(channel
.update_draft("target", "msg_1", "updated")
.await
.is_ok());
assert!(channel
.finalize_draft("target", "msg_1", "final")
.await
.is_ok());
}
// ─────────────────────────────────────────────────────────────────────────────
// Multiple messages: conversation context preservation
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn channel_multiple_sends_preserve_order_and_recipients() {
let channel = CapturingChannel::new();
channel
.send(&SendMessage::new("msg 1", "target_a"))
.await
.unwrap();
channel
.send(&SendMessage::new("msg 2", "target_b"))
.await
.unwrap();
channel
.send(&SendMessage::new("msg 3", "target_a"))
.await
.unwrap();
let sent = channel.sent_messages();
assert_eq!(sent.len(), 3);
assert_eq!(sent[0].recipient, "target_a");
assert_eq!(sent[1].recipient, "target_b");
assert_eq!(sent[2].recipient, "target_a");
assert_eq!(sent[0].content, "msg 1");
assert_eq!(sent[1].content, "msg 2");
assert_eq!(sent[2].content, "msg 3");
}

View File

@@ -0,0 +1,96 @@
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use zeroclaw::hooks::{HookHandler, HookResult, HookRunner};
use zeroclaw::tools::ToolResult;
struct CounterHook {
gateway_starts: Arc<AtomicUsize>,
tool_calls: Arc<AtomicUsize>,
}
#[async_trait]
impl HookHandler for CounterHook {
fn name(&self) -> &str {
"counter"
}
async fn on_gateway_start(&self, _host: &str, _port: u16) {
self.gateway_starts.fetch_add(1, Ordering::SeqCst);
}
async fn on_after_tool_call(&self, _tool: &str, _result: &ToolResult, _duration: Duration) {
self.tool_calls.fetch_add(1, Ordering::SeqCst);
}
}
struct ToolBlocker {
blocked_tools: Vec<String>,
}
#[async_trait]
impl HookHandler for ToolBlocker {
fn name(&self) -> &str {
"tool-blocker"
}
fn priority(&self) -> i32 {
100
}
async fn before_tool_call(
&self,
name: String,
args: serde_json::Value,
) -> HookResult<(String, serde_json::Value)> {
if self.blocked_tools.contains(&name) {
HookResult::Cancel(format!("{name} is blocked"))
} else {
HookResult::Continue((name, args))
}
}
}
#[tokio::test]
async fn hook_runner_full_pipeline() {
let gateway_starts = Arc::new(AtomicUsize::new(0));
let tool_calls = Arc::new(AtomicUsize::new(0));
let mut runner = HookRunner::new();
runner.register(Box::new(CounterHook {
gateway_starts: gateway_starts.clone(),
tool_calls: tool_calls.clone(),
}));
runner.register(Box::new(ToolBlocker {
blocked_tools: vec!["dangerous".into()],
}));
// Void hook: fire gateway start
runner.fire_gateway_start("127.0.0.1", 8080).await;
assert_eq!(gateway_starts.load(Ordering::SeqCst), 1);
// Modifying hook: safe tool passes through
let result = runner
.run_before_tool_call("safe_tool".into(), serde_json::json!({}))
.await;
assert!(!result.is_cancel());
// Modifying hook: dangerous tool is blocked
let result = runner
.run_before_tool_call("dangerous".into(), serde_json::json!({}))
.await;
assert!(result.is_cancel());
// Void hook: fire after tool call increments counter
let tool_result = ToolResult {
success: true,
output: "ok".into(),
error: None,
};
runner
.fire_after_tool_call("safe_tool", &tool_result, Duration::from_millis(10))
.await;
assert_eq!(tool_calls.load(Ordering::SeqCst), 1);
}

View File

@@ -0,0 +1,453 @@
//! Head-to-head comparison: SQLite vs Markdown memory backends
//!
//! Run with: cargo test --test memory_comparison -- --nocapture
use std::time::Instant;
use tempfile::TempDir;
// We test both backends through the public memory module
use zeroclaw::memory::{markdown::MarkdownMemory, sqlite::SqliteMemory, Memory, MemoryCategory};
// ── Helpers ────────────────────────────────────────────────────
fn sqlite_backend(dir: &std::path::Path) -> SqliteMemory {
SqliteMemory::new(dir).expect("SQLite init failed")
}
fn markdown_backend(dir: &std::path::Path) -> MarkdownMemory {
MarkdownMemory::new(dir)
}
// ── Test 1: Store performance ──────────────────────────────────
#[tokio::test]
async fn compare_store_speed() {
let tmp_sq = TempDir::new().unwrap();
let tmp_md = TempDir::new().unwrap();
let sq = sqlite_backend(tmp_sq.path());
let md = markdown_backend(tmp_md.path());
let n = 100;
// SQLite: 100 stores
let start = Instant::now();
for i in 0..n {
sq.store(
&format!("key_{i}"),
&format!("Memory entry number {i} about Rust programming"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
let sq_dur = start.elapsed();
// Markdown: 100 stores
let start = Instant::now();
for i in 0..n {
md.store(
&format!("key_{i}"),
&format!("Memory entry number {i} about Rust programming"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
let md_dur = start.elapsed();
println!("\n============================================================");
println!("STORE {n} entries:");
println!(" SQLite: {:?}", sq_dur);
println!(" Markdown: {:?}", md_dur);
// Both should succeed
assert_eq!(sq.count().await.unwrap(), n);
// Markdown count parses lines, may differ slightly from n
let md_count = md.count().await.unwrap();
assert!(md_count >= n, "Markdown stored {md_count}, expected >= {n}");
}
// ── Test 2: Recall / search quality ────────────────────────────
#[tokio::test]
async fn compare_recall_quality() {
let tmp_sq = TempDir::new().unwrap();
let tmp_md = TempDir::new().unwrap();
let sq = sqlite_backend(tmp_sq.path());
let md = markdown_backend(tmp_md.path());
// Seed both with identical data
let entries = vec![
(
"lang_pref",
"User prefers Rust over Python",
MemoryCategory::Core,
),
(
"editor",
"Uses VS Code with rust-analyzer",
MemoryCategory::Core,
),
("tz", "Timezone is EST, works 9-5", MemoryCategory::Core),
(
"proj1",
"Working on ZeroClaw AI assistant",
MemoryCategory::Daily,
),
(
"proj2",
"Previous project was a web scraper in Python",
MemoryCategory::Daily,
),
(
"deploy",
"Deploys to Hetzner VPS via Docker",
MemoryCategory::Core,
),
(
"model",
"Prefers Claude Sonnet for coding tasks",
MemoryCategory::Core,
),
(
"style",
"Likes concise responses, no fluff",
MemoryCategory::Core,
),
(
"rust_note",
"Rust's ownership model prevents memory bugs",
MemoryCategory::Daily,
),
(
"perf",
"Cares about binary size and startup time",
MemoryCategory::Core,
),
];
for (key, content, cat) in &entries {
sq.store(key, content, cat.clone(), None).await.unwrap();
md.store(key, content, cat.clone(), None).await.unwrap();
}
// Test queries and compare results
let queries = vec![
("Rust", "Should find Rust-related entries"),
("Python", "Should find Python references"),
("deploy Docker", "Multi-keyword search"),
("Claude", "Specific tool reference"),
("javascript", "No matches expected"),
("binary size startup", "Multi-keyword partial match"),
];
println!("\n============================================================");
println!("RECALL QUALITY (10 entries seeded):\n");
for (query, desc) in &queries {
let sq_results = sq.recall(query, 10, None, None, None).await.unwrap();
let md_results = md.recall(query, 10, None, None, None).await.unwrap();
println!(" Query: \"{query}\"{desc}");
println!(" SQLite: {} results", sq_results.len());
for r in &sq_results {
println!(
" [{:.2}] {}: {}",
r.score.unwrap_or(0.0),
r.key,
&r.content[..r.content.len().min(50)]
);
}
println!(" Markdown: {} results", md_results.len());
for r in &md_results {
println!(
" [{:.2}] {}: {}",
r.score.unwrap_or(0.0),
r.key,
&r.content[..r.content.len().min(50)]
);
}
println!();
}
}
// ── Test 3: Recall speed at scale ──────────────────────────────
#[tokio::test]
async fn compare_recall_speed() {
let tmp_sq = TempDir::new().unwrap();
let tmp_md = TempDir::new().unwrap();
let sq = sqlite_backend(tmp_sq.path());
let md = markdown_backend(tmp_md.path());
// Seed 200 entries
let n = 200;
for i in 0..n {
let content = if i % 3 == 0 {
format!("Rust is great for systems programming, entry {i}")
} else if i % 3 == 1 {
format!("Python is popular for data science, entry {i}")
} else {
format!("TypeScript powers modern web apps, entry {i}")
};
sq.store(&format!("e{i}"), &content, MemoryCategory::Core, None)
.await
.unwrap();
md.store(&format!("e{i}"), &content, MemoryCategory::Daily, None)
.await
.unwrap();
}
// Benchmark recall
let start = Instant::now();
let sq_results = sq
.recall("Rust systems", 10, None, None, None)
.await
.unwrap();
let sq_dur = start.elapsed();
let start = Instant::now();
let md_results = md
.recall("Rust systems", 10, None, None, None)
.await
.unwrap();
let md_dur = start.elapsed();
println!("\n============================================================");
println!("RECALL from {n} entries (query: \"Rust systems\", limit 10):");
println!(" SQLite: {:?}{} results", sq_dur, sq_results.len());
println!(" Markdown: {:?}{} results", md_dur, md_results.len());
// Both should find results
assert!(!sq_results.is_empty());
assert!(!md_results.is_empty());
}
// ── Test 4: Persistence (SQLite wins by design) ────────────────
#[tokio::test]
async fn compare_persistence() {
let tmp_sq = TempDir::new().unwrap();
let tmp_md = TempDir::new().unwrap();
// Store in both, then drop and re-open
{
let sq = sqlite_backend(tmp_sq.path());
sq.store(
"persist_test",
"I should survive",
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
{
let md = markdown_backend(tmp_md.path());
md.store(
"persist_test",
"I should survive",
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
// Re-open
let sq2 = sqlite_backend(tmp_sq.path());
let md2 = markdown_backend(tmp_md.path());
let sq_entry = sq2.get("persist_test").await.unwrap();
let md_entry = md2.get("persist_test").await.unwrap();
println!("\n============================================================");
println!("PERSISTENCE (store → drop → re-open → get):");
println!(
" SQLite: {}",
if sq_entry.is_some() {
"✅ Survived"
} else {
"❌ Lost"
}
);
println!(
" Markdown: {}",
if md_entry.is_some() {
"✅ Survived"
} else {
"❌ Lost"
}
);
// SQLite should always persist by key
assert!(sq_entry.is_some());
assert_eq!(sq_entry.unwrap().content, "I should survive");
// Markdown persists content to files (get uses content search)
assert!(md_entry.is_some());
}
// ── Test 5: Upsert / update behavior ──────────────────────────
#[tokio::test]
async fn compare_upsert() {
let tmp_sq = TempDir::new().unwrap();
let tmp_md = TempDir::new().unwrap();
let sq = sqlite_backend(tmp_sq.path());
let md = markdown_backend(tmp_md.path());
// Store twice with same key, different content
sq.store("pref", "likes Rust", MemoryCategory::Core, None)
.await
.unwrap();
sq.store("pref", "loves Rust", MemoryCategory::Core, None)
.await
.unwrap();
md.store("pref", "likes Rust", MemoryCategory::Core, None)
.await
.unwrap();
md.store("pref", "loves Rust", MemoryCategory::Core, None)
.await
.unwrap();
let sq_count = sq.count().await.unwrap();
let md_count = md.count().await.unwrap();
let sq_entry = sq.get("pref").await.unwrap();
let md_results = md.recall("loves Rust", 5, None, None, None).await.unwrap();
println!("\n============================================================");
println!("UPSERT (store same key twice):");
println!(
" SQLite: count={sq_count}, latest=\"{}\"",
sq_entry.as_ref().map_or("none", |e| &e.content)
);
println!(" Markdown: count={md_count} (append-only, both entries kept)");
println!(" Can still find latest: {}", !md_results.is_empty());
// SQLite: upsert replaces, count stays at 1
assert_eq!(sq_count, 1);
assert_eq!(sq_entry.unwrap().content, "loves Rust");
// Markdown: append-only, count increases
assert!(md_count >= 2, "Markdown should keep both entries");
}
// ── Test 6: Forget / delete capability ─────────────────────────
#[tokio::test]
async fn compare_forget() {
let tmp_sq = TempDir::new().unwrap();
let tmp_md = TempDir::new().unwrap();
let sq = sqlite_backend(tmp_sq.path());
let md = markdown_backend(tmp_md.path());
sq.store("secret", "API key: sk-1234", MemoryCategory::Core, None)
.await
.unwrap();
md.store("secret", "API key: sk-1234", MemoryCategory::Core, None)
.await
.unwrap();
let sq_forgot = sq.forget("secret").await.unwrap();
let md_forgot = md.forget("secret").await.unwrap();
println!("\n============================================================");
println!("FORGET (delete sensitive data):");
println!(
" SQLite: {} (count={})",
if sq_forgot { "✅ Deleted" } else { "❌ Kept" },
sq.count().await.unwrap()
);
println!(
" Markdown: {} (append-only by design)",
if md_forgot {
"✅ Deleted"
} else {
"⚠️ Cannot delete (audit trail)"
},
);
// SQLite can delete
assert!(sq_forgot);
assert_eq!(sq.count().await.unwrap(), 0);
// Markdown cannot delete (by design)
assert!(!md_forgot);
}
// ── Test 7: Category filtering ─────────────────────────────────
#[tokio::test]
async fn compare_category_filter() {
let tmp_sq = TempDir::new().unwrap();
let tmp_md = TempDir::new().unwrap();
let sq = sqlite_backend(tmp_sq.path());
let md = markdown_backend(tmp_md.path());
// Mix of categories
sq.store("a", "core fact 1", MemoryCategory::Core, None)
.await
.unwrap();
sq.store("b", "core fact 2", MemoryCategory::Core, None)
.await
.unwrap();
sq.store("c", "daily note", MemoryCategory::Daily, None)
.await
.unwrap();
sq.store("d", "convo msg", MemoryCategory::Conversation, None)
.await
.unwrap();
md.store("a", "core fact 1", MemoryCategory::Core, None)
.await
.unwrap();
md.store("b", "core fact 2", MemoryCategory::Core, None)
.await
.unwrap();
md.store("c", "daily note", MemoryCategory::Daily, None)
.await
.unwrap();
let sq_core = sq.list(Some(&MemoryCategory::Core), None).await.unwrap();
let sq_daily = sq.list(Some(&MemoryCategory::Daily), None).await.unwrap();
let sq_conv = sq
.list(Some(&MemoryCategory::Conversation), None)
.await
.unwrap();
let sq_all = sq.list(None, None).await.unwrap();
let md_core = md.list(Some(&MemoryCategory::Core), None).await.unwrap();
let md_daily = md.list(Some(&MemoryCategory::Daily), None).await.unwrap();
let md_all = md.list(None, None).await.unwrap();
println!("\n============================================================");
println!("CATEGORY FILTERING:");
println!(
" SQLite: core={}, daily={}, conv={}, all={}",
sq_core.len(),
sq_daily.len(),
sq_conv.len(),
sq_all.len()
);
println!(
" Markdown: core={}, daily={}, all={}",
md_core.len(),
md_daily.len(),
md_all.len()
);
// SQLite: precise category filtering via SQL WHERE
assert_eq!(sq_core.len(), 2);
assert_eq!(sq_daily.len(), 1);
assert_eq!(sq_conv.len(), 1);
assert_eq!(sq_all.len(), 4);
// Markdown: categories determined by file location
assert!(!md_core.is_empty());
assert!(!md_all.is_empty());
}

View File

@@ -0,0 +1,375 @@
//! TG5: Memory Restart Resilience Tests
//!
//! Prevents: Pattern 5 — Memory & state persistence bugs (10% of user bugs).
//! Issues: #430, #693, #802
//!
//! Tests SqliteMemory deduplication on restart, session scoping, concurrent
//! message ordering, and recall behavior after re-initialization.
use std::sync::Arc;
use zeroclaw::memory::sqlite::SqliteMemory;
use zeroclaw::memory::traits::{Memory, MemoryCategory};
// ─────────────────────────────────────────────────────────────────────────────
// Deduplication: same key overwrites instead of duplicating (#430)
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_store_same_key_deduplicates() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
// Store same key twice with different content
mem.store("greeting", "hello world", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("greeting", "hello updated", MemoryCategory::Core, None)
.await
.unwrap();
// Should have exactly 1 entry, not 2
let count = mem.count().await.unwrap();
assert_eq!(
count, 1,
"storing same key twice should not create duplicates"
);
// Content should be the latest version
let entry = mem
.get("greeting")
.await
.unwrap()
.expect("entry should exist");
assert_eq!(entry.content, "hello updated");
}
#[tokio::test]
async fn sqlite_memory_store_different_keys_creates_separate_entries() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("key_a", "content a", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("key_b", "content b", MemoryCategory::Core, None)
.await
.unwrap();
let count = mem.count().await.unwrap();
assert_eq!(count, 2, "different keys should create separate entries");
}
// ─────────────────────────────────────────────────────────────────────────────
// Restart resilience: data persists across memory re-initialization
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_persists_across_reinitialization() {
let tmp = tempfile::TempDir::new().unwrap();
// First "session": store data
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store(
"persistent_fact",
"Rust is great",
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
// Second "session": re-create memory from same path
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
let entry = mem
.get("persistent_fact")
.await
.unwrap()
.expect("entry should survive reinitialization");
assert_eq!(entry.content, "Rust is great");
}
}
#[tokio::test]
async fn sqlite_memory_restart_does_not_duplicate_on_rewrite() {
let tmp = tempfile::TempDir::new().unwrap();
// First session: store entries
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("fact_1", "original content", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
.await
.unwrap();
}
// Second session: re-store same keys (simulates channel re-reading history)
{
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("fact_1", "original content", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("fact_2", "another fact", MemoryCategory::Core, None)
.await
.unwrap();
let count = mem.count().await.unwrap();
assert_eq!(
count, 2,
"re-storing same keys after restart should not create duplicates"
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Session scoping: messages scoped to sessions don't leak
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_session_scoped_store_and_recall() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
// Store in different sessions
mem.store(
"session_a_fact",
"fact from session A",
MemoryCategory::Conversation,
Some("session_a"),
)
.await
.unwrap();
mem.store(
"session_b_fact",
"fact from session B",
MemoryCategory::Conversation,
Some("session_b"),
)
.await
.unwrap();
// List scoped to session_a
let session_a_entries = mem
.list(Some(&MemoryCategory::Conversation), Some("session_a"))
.await
.unwrap();
assert_eq!(
session_a_entries.len(),
1,
"session_a should have exactly 1 entry"
);
assert_eq!(session_a_entries[0].content, "fact from session A");
}
#[tokio::test]
async fn sqlite_memory_global_recall_includes_all_sessions() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store(
"global_a",
"alpha content",
MemoryCategory::Core,
Some("s1"),
)
.await
.unwrap();
mem.store("global_b", "beta content", MemoryCategory::Core, Some("s2"))
.await
.unwrap();
// Global count should include all
let count = mem.count().await.unwrap();
assert_eq!(
count, 2,
"global count should include entries from all sessions"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Recall and search behavior
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_recall_returns_relevant_results() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store(
"lang_pref",
"User prefers Rust programming",
MemoryCategory::Core,
None,
)
.await
.unwrap();
mem.store(
"food_pref",
"User likes sushi for lunch",
MemoryCategory::Core,
None,
)
.await
.unwrap();
let results = mem
.recall("Rust programming", 10, None, None, None)
.await
.unwrap();
assert!(!results.is_empty(), "recall should find matching entries");
// The Rust-related entry should be in results
assert!(
results.iter().any(|e| e.content.contains("Rust")),
"recall for 'Rust' should include the Rust-related entry"
);
}
#[tokio::test]
async fn sqlite_memory_recall_respects_limit() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
for i in 0..10 {
mem.store(
&format!("entry_{i}"),
&format!("test content number {i}"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
}
let results = mem
.recall("test content", 3, None, None, None)
.await
.unwrap();
assert!(
results.len() <= 3,
"recall should respect limit of 3, got {}",
results.len()
);
}
#[tokio::test]
async fn sqlite_memory_recall_empty_query_returns_recent_entries() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("fact", "some content", MemoryCategory::Core, None)
.await
.unwrap();
// Empty query uses time-only path: returns recent entries by updated_at
let results = mem.recall("", 10, None, None, None).await.unwrap();
assert_eq!(results.len(), 1, "empty query should return recent entries");
assert_eq!(results[0].key, "fact");
}
// ─────────────────────────────────────────────────────────────────────────────
// Forget and health check
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_forget_removes_entry() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("to_forget", "temporary info", MemoryCategory::Core, None)
.await
.unwrap();
assert_eq!(mem.count().await.unwrap(), 1);
let removed = mem.forget("to_forget").await.unwrap();
assert!(removed, "forget should return true for existing key");
assert_eq!(mem.count().await.unwrap(), 0);
}
#[tokio::test]
async fn sqlite_memory_forget_nonexistent_returns_false() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
let removed = mem.forget("nonexistent_key").await.unwrap();
assert!(!removed, "forget should return false for nonexistent key");
}
#[tokio::test]
async fn sqlite_memory_health_check_returns_true() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
assert!(mem.health_check().await, "health_check should return true");
}
// ─────────────────────────────────────────────────────────────────────────────
// Concurrent access
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_concurrent_stores_no_data_loss() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = Arc::new(SqliteMemory::new(tmp.path()).unwrap());
let mut handles = Vec::new();
for i in 0..5 {
let mem_clone = mem.clone();
handles.push(tokio::spawn(async move {
mem_clone
.store(
&format!("concurrent_{i}"),
&format!("content from task {i}"),
MemoryCategory::Core,
None,
)
.await
.unwrap();
}));
}
for handle in handles {
handle.await.unwrap();
}
let count = mem.count().await.unwrap();
assert_eq!(
count, 5,
"all concurrent stores should succeed, got {count}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Memory categories
// ─────────────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn sqlite_memory_list_by_category() {
let tmp = tempfile::TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("core_fact", "core info", MemoryCategory::Core, None)
.await
.unwrap();
mem.store("daily_note", "daily note", MemoryCategory::Daily, None)
.await
.unwrap();
mem.store(
"conv_msg",
"conversation msg",
MemoryCategory::Conversation,
None,
)
.await
.unwrap();
let core_entries = mem.list(Some(&MemoryCategory::Core), None).await.unwrap();
assert_eq!(core_entries.len(), 1, "should have 1 Core entry");
assert_eq!(core_entries[0].key, "core_fact");
let daily_entries = mem.list(Some(&MemoryCategory::Daily), None).await.unwrap();
assert_eq!(daily_entries.len(), 1, "should have 1 Daily entry");
}

View File

@@ -0,0 +1,11 @@
mod agent;
mod agent_robustness;
mod backup_cron_scheduling;
mod channel_matrix;
mod channel_routing;
mod hooks;
mod memory_comparison;
mod memory_restart;
mod report_template_tool_test;
mod telegram_attachment_fallback;
mod telegram_finalize_draft;

View File

@@ -0,0 +1,238 @@
//! Integration tests for ReportTemplateTool.
use serde_json::json;
use zeroclaw::tools::{ReportTemplateTool, Tool};
#[tokio::test]
async fn render_weekly_status_en() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "weekly_status",
"language": "en",
"variables": {
"project_name": "Acme Platform",
"period": "2026-W10",
"completed": "- Task A\n- Task B",
"in_progress": "- Task C",
"blocked": "None",
"next_steps": "- Task D"
}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
assert!(result.output.contains("Project: Acme Platform"));
assert!(result.output.contains("Period: 2026-W10"));
assert!(result.output.contains("- Task A"));
assert!(result.output.contains("## Completed"));
}
#[tokio::test]
async fn render_sprint_review_de() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "sprint_review",
"language": "de",
"variables": {
"sprint_dates": "2026-03-01 bis 2026-03-14",
"completed": "Feature X implementiert",
"in_progress": "Feature Y",
"blocked": "Keine",
"velocity": "12 Story Points"
}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
assert!(result.output.contains("## Sprint"));
assert!(result.output.contains("## Erledigt"));
assert!(result.output.contains("Feature X implementiert"));
}
#[tokio::test]
async fn render_risk_register_fr() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "risk_register",
"language": "fr",
"variables": {
"project_name": "Projet Alpha",
"risks": "Risque de retard",
"mitigations": "Augmenter les ressources"
}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
assert!(result.output.contains("## Projet"));
assert!(result.output.contains("## Risques"));
assert!(result.output.contains("Risque de retard"));
}
#[tokio::test]
async fn render_milestone_report_it() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "milestone_report",
"language": "it",
"variables": {
"project_name": "Progetto Beta",
"milestones": "M1: Completato\nM2: In corso",
"status": "In linea con i tempi"
}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
assert!(result.output.contains("## Progetto"));
assert!(result.output.contains("## Milestone"));
assert!(result.output.contains("M1: Completato"));
}
#[tokio::test]
async fn default_language_is_en() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "weekly_status",
"variables": {
"project_name": "Test",
"period": "W1",
"completed": "Done",
"in_progress": "WIP",
"blocked": "None",
"next_steps": "Next"
}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
assert!(result.output.contains("## Summary"));
assert!(result.output.contains("## Completed"));
}
#[tokio::test]
async fn missing_template_param_fails() {
let tool = ReportTemplateTool::new();
let params = json!({
"variables": {
"project_name": "Test"
}
});
let result = tool.execute(params).await;
assert!(result.is_err());
let error = result.unwrap_err().to_string();
assert!(error.contains("missing template"));
}
#[tokio::test]
async fn missing_variables_param_fails() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "weekly_status"
});
let result = tool.execute(params).await;
assert!(result.is_err());
let error = result.unwrap_err().to_string();
assert!(error.contains("variables must be object"));
}
#[tokio::test]
async fn invalid_template_name_fails() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "unknown_template",
"variables": {
"project_name": "Test"
}
});
let result = tool.execute(params).await;
assert!(result.is_err());
}
#[tokio::test]
async fn invalid_language_code_fails() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "weekly_status",
"language": "es",
"variables": {
"project_name": "Test"
}
});
let result = tool.execute(params).await;
// Note: The current implementation doesn't fail on invalid language,
// it falls back to English. We test this behavior.
let result = result.unwrap();
assert!(result.success);
// Should render in English (default fallback)
assert!(result.output.contains("## Summary"));
}
#[tokio::test]
async fn empty_variables_map_renders() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "weekly_status",
"variables": {}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
// Placeholders should remain unchanged
assert!(result.output.contains("{{project_name}}"));
assert!(result.output.contains("{{period}}"));
}
#[tokio::test]
async fn injection_protection_enforced() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "weekly_status",
"variables": {
"project_name": "Test {{injected}}",
"period": "W1",
"completed": "{{nested_var}}",
"in_progress": "WIP",
"blocked": "None",
"next_steps": "Next",
"injected": "SHOULD_NOT_APPEAR",
"nested_var": "SHOULD_NOT_EXPAND"
}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
// The value "Test {{injected}}" should be inserted literally
assert!(result.output.contains("Test {{injected}}"));
// The nested variable should not be expanded recursively
assert!(result.output.contains("{{nested_var}}"));
// The injected values should not appear
assert!(!result.output.contains("SHOULD_NOT_APPEAR"));
assert!(!result.output.contains("SHOULD_NOT_EXPAND"));
}
#[tokio::test]
async fn non_string_variable_values_coerced() {
let tool = ReportTemplateTool::new();
let params = json!({
"template": "weekly_status",
"variables": {
"project_name": "Test",
"period": 123,
"completed": true,
"in_progress": false,
"blocked": null,
"next_steps": ["array", "not", "supported"]
}
});
let result = tool.execute(params).await.unwrap();
assert!(result.success);
// Numbers and booleans should be coerced to strings
// null and arrays should result in empty strings
assert!(result.output.contains("Project: Test"));
}

View File

@@ -0,0 +1,298 @@
//! Regression tests for Telegram attachment fallback behavior.
//!
//! When sending media by URL fails (e.g. Telegram can't fetch the URL or the
//! content type is wrong), the channel should fall back to sending the URL as
//! a text link instead of losing the entire reply.
//!
//! Bug: Previously, `send_attachment()` would propagate the error from
//! `send_document_by_url()` immediately via `?`, causing the entire reply
//! (including already-sent text) to fail with no fallback.
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
use zeroclaw::channels::telegram::TelegramChannel;
use zeroclaw::channels::traits::{Channel, SendMessage};
/// Helper: create a TelegramChannel pointing at a mock server.
fn test_channel(mock_url: &str) -> TelegramChannel {
TelegramChannel::new("TEST_TOKEN".into(), vec!["*".into()], false)
.with_api_base(mock_url.to_string())
}
/// Helper: mount a mock that accepts sendMessage requests (the fallback path).
async fn mock_send_message_ok(server: &MockServer) {
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 123},
"text": "ok"
}
})))
.expect(1..)
.mount(server)
.await;
}
/// When sendDocument by URL fails with "wrong type of the web page content",
/// the channel should fall back to sending the URL as a text link.
#[tokio::test]
async fn document_url_failure_falls_back_to_text_link() {
let server = MockServer::start().await;
// sendDocument returns 400 (simulates Telegram rejecting the URL)
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: wrong type of the web page content"
})))
.expect(1)
.mount(&server)
.await;
// sendMessage should succeed (this is the fallback)
mock_send_message_ok(&server).await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Here is the report [DOCUMENT:https://example.com/page.html]",
"123",
);
// This should NOT error — it should fall back to text
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed via text fallback, got: {result:?}"
);
}
/// When sendPhoto by URL fails, the channel should fall back to text link.
#[tokio::test]
async fn photo_url_failure_falls_back_to_text_link() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendPhoto$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: failed to get HTTP URL content"
})))
.expect(1)
.mount(&server)
.await;
mock_send_message_ok(&server).await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Check this [IMAGE:https://internal-server.local/screenshot.png]",
"456",
);
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed via text fallback, got: {result:?}"
);
}
/// Text portion of a message with attachments is still delivered even when
/// the attachment fails.
#[tokio::test]
async fn text_portion_delivered_before_attachment_failure() {
let server = MockServer::start().await;
// sendDocument fails
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: wrong type of the web page content"
})))
.expect(1)
.mount(&server)
.await;
// sendMessage should be called at least twice:
// 1. for the text portion ("Here is the file")
// 2. for the fallback text link
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 789},
"text": "ok"
}
})))
.expect(2)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Here is the file [DOCUMENT:https://example.com/report.html]",
"789",
);
let result = channel.send(&msg).await;
assert!(result.is_ok(), "send should succeed, got: {result:?}");
}
/// When multiple attachments are present and one fails, the others should
/// still be attempted (each gets its own fallback).
#[tokio::test]
async fn multiple_attachments_independent_fallback() {
let server = MockServer::start().await;
// sendDocument fails (for the .html attachment)
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: wrong type of the web page content"
})))
.expect(1)
.mount(&server)
.await;
// sendPhoto also fails
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendPhoto$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: failed to get HTTP URL content"
})))
.expect(1)
.mount(&server)
.await;
// sendMessage succeeds (text + 2 fallback links)
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 100},
"text": "ok"
}
})))
.expect(3) // text + doc fallback + image fallback
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Files: [DOCUMENT:https://example.com/page.html] and [IMAGE:https://internal.local/pic.png]",
"100",
);
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed with fallbacks for all attachments, got: {result:?}"
);
}
/// When attachment succeeds, no fallback text is sent.
#[tokio::test]
async fn successful_attachment_no_fallback() {
let server = MockServer::start().await;
// sendDocument succeeds
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 2,
"chat": {"id": 200},
"document": {"file_id": "abc"}
}
})))
.expect(1)
.mount(&server)
.await;
// sendMessage should only be called once (for the text portion),
// NOT a second time for a fallback
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 200},
"text": "ok"
}
})))
.expect(1) // only the text portion, no fallback
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let msg = SendMessage::new(
"Report attached [DOCUMENT:https://example.com/report.pdf]",
"200",
);
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"send should succeed normally, got: {result:?}"
);
}
/// Document-only message (no text) with URL failure should still send
/// a fallback text link.
#[tokio::test]
async fn document_only_message_falls_back_to_text() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendDocument$"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"ok": false,
"error_code": 400,
"description": "Bad Request: failed to get HTTP URL content"
})))
.expect(1)
.mount(&server)
.await;
// Fallback text link
Mock::given(method("POST"))
.and(path_regex(r"/botTEST_TOKEN/sendMessage$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {
"message_id": 1,
"chat": {"id": 300},
"text": "ok"
}
})))
.expect(1)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
// Message is ONLY the attachment marker — no surrounding text
let msg = SendMessage::new("[DOCUMENT:https://example.com/file.html]", "300");
let result = channel.send(&msg).await;
assert!(
result.is_ok(),
"document-only message should fall back to text, got: {result:?}"
);
}

View File

@@ -0,0 +1,208 @@
use serde_json::json;
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use zeroclaw::channels::telegram::TelegramChannel;
use zeroclaw::channels::traits::Channel;
fn test_channel(mock_url: &str) -> TelegramChannel {
TelegramChannel::new("TEST_TOKEN".into(), vec!["*".into()], false)
.with_api_base(mock_url.to_string())
}
fn telegram_ok_response(message_id: i64) -> serde_json::Value {
json!({
"ok": true,
"result": {
"message_id": message_id,
"chat": {"id": 123},
"text": "ok"
}
})
}
fn telegram_error_response(description: &str) -> serde_json::Value {
json!({
"ok": false,
"error_code": 400,
"description": description,
})
}
#[tokio::test]
async fn finalize_draft_treats_not_modified_as_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/editMessageText"))
.respond_with(
ResponseTemplate::new(400).set_body_json(telegram_error_response(
"Bad Request: message is not modified",
)),
)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let result = channel.finalize_draft("123", "42", "final text").await;
assert!(
result.is_ok(),
"not modified should be treated as success, got: {result:?}"
);
let requests = server
.received_requests()
.await
.expect("requests should be captured");
assert_eq!(requests.len(), 1, "should stop after first edit response");
assert_eq!(requests[0].url.path(), "/botTEST_TOKEN/editMessageText");
}
#[tokio::test]
async fn finalize_draft_plain_retry_treats_not_modified_as_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/editMessageText"))
.and(body_partial_json(json!({
"chat_id": "123",
"message_id": 42,
"parse_mode": "HTML",
})))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(telegram_error_response("Bad Request: can't parse entities")),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/editMessageText"))
.and(body_partial_json(json!({
"chat_id": "123",
"message_id": 42,
"text": "Use **bold**",
})))
.respond_with(
ResponseTemplate::new(400).set_body_json(telegram_error_response(
"Bad Request: message is not modified",
)),
)
.expect(1)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let result = channel.finalize_draft("123", "42", "Use **bold**").await;
assert!(
result.is_ok(),
"plain retry should accept not modified, got: {result:?}"
);
let requests = server
.received_requests()
.await
.expect("requests should be captured");
assert_eq!(requests.len(), 2, "should only attempt the two edit calls");
}
#[tokio::test]
async fn finalize_draft_skips_send_message_when_delete_fails() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/editMessageText"))
.respond_with(
ResponseTemplate::new(400).set_body_json(telegram_error_response(
"Bad Request: message cannot be edited",
)),
)
.expect(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/deleteMessage"))
.respond_with(
ResponseTemplate::new(400).set_body_json(telegram_error_response(
"Bad Request: message to delete not found",
)),
)
.expect(1)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let result = channel.finalize_draft("123", "42", "final text").await;
assert!(
result.is_ok(),
"delete failure should skip sendMessage instead of erroring, got: {result:?}"
);
let requests = server
.received_requests()
.await
.expect("requests should be captured");
assert_eq!(
requests
.iter()
.filter(|req| req.url.path() == "/botTEST_TOKEN/sendMessage")
.count(),
0,
"sendMessage should be skipped when deleteMessage fails"
);
}
#[tokio::test]
async fn finalize_draft_sends_fresh_message_after_successful_delete() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/editMessageText"))
.respond_with(
ResponseTemplate::new(400).set_body_json(telegram_error_response(
"Bad Request: message cannot be edited",
)),
)
.expect(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/deleteMessage"))
.respond_with(ResponseTemplate::new(200).set_body_json(telegram_ok_response(42)))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/botTEST_TOKEN/sendMessage"))
.respond_with(ResponseTemplate::new(200).set_body_json(telegram_ok_response(43)))
.expect(1)
.mount(&server)
.await;
let channel = test_channel(&server.uri());
let result = channel.finalize_draft("123", "42", "final text").await;
assert!(
result.is_ok(),
"successful delete should allow safe sendMessage fallback, got: {result:?}"
);
let requests = server
.received_requests()
.await
.expect("requests should be captured");
assert_eq!(
requests
.iter()
.filter(|req| req.url.path() == "/botTEST_TOKEN/sendMessage")
.count(),
1,
"sendMessage should be attempted exactly once after delete succeeds"
);
}

View File

@@ -0,0 +1,236 @@
//! E2E test for Gemini fallback with OAuth token refresh.
//!
//! This test validates that when:
//! 1. Primary provider (OpenAI Codex) fails
//! 2. Fallback to Gemini is triggered
//! 3. Gemini OAuth tokens are expired (we manually expire them)
//!
//! Then:
//! - Gemini provider's warmup() automatically refreshes the tokens
//! - The fallback request succeeds
//!
//! Requires:
//! - Live Gemini OAuth profile in `~/.zeroclaw/auth-profiles.json` with refresh_token
//! - GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET env vars
//!
//! Run manually: `cargo test gemini_fallback_oauth_refresh -- --ignored --nocapture`
use anyhow::Result;
use chrono::{Duration, Utc};
use serde_json::Value;
use std::env;
use std::fs;
use std::path::PathBuf;
/// Tests that Gemini warmup() refreshes expired OAuth tokens.
///
/// This test:
/// 1. Backs up real auth-profiles.json
/// 2. Modifies it to set Gemini token as expired
/// 3. Creates a Gemini provider and calls warmup()
/// 4. Verifies token was refreshed
/// 5. Restores original auth-profiles.json
#[tokio::test]
#[ignore = "requires live Gemini OAuth credentials with refresh_token"]
async fn gemini_warmup_refreshes_expired_oauth_token() -> Result<()> {
// Find ~/.zeroclaw/auth-profiles.json
let home = env::var("HOME").expect("HOME env var not set");
let zeroclaw_dir = PathBuf::from(home).join(".zeroclaw");
let auth_profiles_path = zeroclaw_dir.join("auth-profiles.json");
if !auth_profiles_path.exists() {
eprintln!(
"⚠️ No auth-profiles.json found at {:?}",
auth_profiles_path
);
eprintln!("Run: zeroclaw auth login --provider gemini");
return Ok(());
}
// Load current auth-profiles.json
let original_content = fs::read_to_string(&auth_profiles_path)?;
let mut data: Value = serde_json::from_str(&original_content)?;
println!("Loaded auth-profiles.json");
// Find Gemini profile
let profiles = data
.get_mut("profiles")
.and_then(|p| p.as_object_mut())
.ok_or_else(|| anyhow::anyhow!("No profiles object in auth-profiles.json"))?;
let gemini_profile_key = profiles
.keys()
.find(|k| k.starts_with("gemini:"))
.ok_or_else(|| {
anyhow::anyhow!(
"No Gemini OAuth profile found. Run: zeroclaw auth login --provider gemini"
)
})?
.clone();
let gemini_profile = profiles
.get_mut(&gemini_profile_key)
.ok_or_else(|| anyhow::anyhow!("Gemini profile not found"))?;
println!("Found Gemini profile: {}", gemini_profile_key);
// Check if profile has refresh_token
if gemini_profile.get("refresh_token").is_none() {
eprintln!("⚠️ Gemini profile has no refresh_token — cannot test refresh");
return Ok(());
}
println!("✓ Gemini profile has refresh_token");
// Backup original expires_at
let original_expires_at = gemini_profile.get("expires_at").cloned();
println!("Original expires_at: {:?}", original_expires_at);
// Set expires_at to 1 hour ago (expired)
let expired_time = Utc::now() - Duration::seconds(3600);
let expired_str = expired_time.to_rfc3339();
gemini_profile
.as_object_mut()
.unwrap()
.insert("expires_at".to_string(), Value::String(expired_str.clone()));
println!("Set expires_at to: {} (expired)", expired_str);
// Ensure we restore original file even if test fails
let restore_guard = scopeguard::guard(original_content.clone(), |backup| {
if let Err(e) = fs::write(&auth_profiles_path, backup) {
eprintln!("⚠️ Failed to restore auth-profiles.json: {}", e);
} else {
println!("✓ Restored original auth-profiles.json");
}
});
// Check required env vars
if env::var("GEMINI_OAUTH_CLIENT_ID").is_err()
|| env::var("GEMINI_OAUTH_CLIENT_SECRET").is_err()
{
eprintln!("⚠️ GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET required for refresh");
return Ok(());
}
// Write modified auth-profiles.json BEFORE creating provider
fs::write(&auth_profiles_path, serde_json::to_string_pretty(&data)?)?;
println!("✓ Wrote modified auth-profiles.json with expired token");
// Small delay to ensure file is flushed
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Create GeminiProvider using the default factory
// This will load auth from ~/.zeroclaw/auth-profiles.json (with expired token)
let provider = zeroclaw::providers::create_provider("gemini", None)?;
println!("Created Gemini provider with expired token");
// Call warmup() — should detect expired token and refresh it
println!("Calling warmup() — should refresh expired token...");
let warmup_result = provider.warmup().await;
if let Err(e) = warmup_result {
eprintln!("❌ warmup() failed: {}", e);
eprintln!("This might be expected if:");
eprintln!(" - GEMINI_OAUTH_CLIENT_ID/SECRET are not set");
eprintln!(" - Refresh token is invalid");
eprintln!(" - Network is unavailable");
return Err(e);
}
println!("✓ warmup() succeeded");
// Small delay to ensure file is written
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Re-load auth-profiles.json to check if token was refreshed
let updated_content = fs::read_to_string(&auth_profiles_path)?;
let updated_data: Value = serde_json::from_str(&updated_content)?;
let updated_profile = updated_data
.get("profiles")
.and_then(|p| p.as_object())
.and_then(|p| p.get(&gemini_profile_key))
.and_then(|p| p.as_object())
.ok_or_else(|| anyhow::anyhow!("Failed to read updated profile"))?;
let new_expires_at = updated_profile.get("expires_at").and_then(|v| v.as_str());
println!("New expires_at: {:?}", new_expires_at);
// Verify token was refreshed (expires_at should be in the future)
if let Some(new_exp) = new_expires_at {
let new_exp_dt = chrono::DateTime::parse_from_rfc3339(new_exp)?;
let now = Utc::now();
let seconds_from_now = new_exp_dt.signed_duration_since(now).num_seconds();
if seconds_from_now > 300 {
println!(
"✓ Token was refreshed! New expiry is {} seconds from now",
seconds_from_now
);
} else {
eprintln!(
"⚠️ Token expiry is NOT in the future: {} seconds from now",
seconds_from_now
);
eprintln!(" This might mean warmup() did not refresh the token.");
eprintln!(" Original: {:?}", original_expires_at);
eprintln!(" Set to (expired): {}", expired_str);
eprintln!(" After warmup: {}", new_exp);
}
} else {
eprintln!("⚠️ No expires_at found after warmup");
}
// Try making a real request to verify token works
println!("\nMaking real request to verify token works...");
let response = provider
.chat_with_system(
Some("You are a concise assistant. Reply in one short sentence."),
"Say 'OAuth refresh works'",
"gemini-2.5-pro",
0.7,
)
.await;
match response {
Ok(text) => {
println!("✓ Request succeeded! Response: {}", text);
assert!(!text.is_empty(), "Response should not be empty");
}
Err(e) => {
eprintln!("❌ Request failed: {}", e);
return Err(e);
}
}
// Cleanup is handled by scopeguard
drop(restore_guard);
println!("\n=== Test Passed ===");
println!("Gemini warmup() correctly refreshed expired OAuth token!");
Ok(())
}
/// Simpler test: just verify warmup() doesn't fail with valid credentials.
/// This test doesn't modify auth-profiles.json.
#[tokio::test]
#[ignore = "requires live Gemini OAuth credentials"]
async fn gemini_warmup_with_valid_credentials() -> Result<()> {
// Create provider from default config
let provider = zeroclaw::providers::create_provider("gemini", None)?;
println!("Created Gemini provider");
println!("Calling warmup()...");
// This should succeed if credentials are valid
provider.warmup().await?;
println!("✓ warmup() succeeded with valid credentials");
Ok(())
}

View File

@@ -0,0 +1,3 @@
mod gemini_fallback_oauth_refresh;
mod openai_codex_vision_e2e;
mod providers;

View File

@@ -0,0 +1,268 @@
//! E2E test for vision support in providers.
//!
//! This test validates that:
//! 1. Provider reports vision capability
//! 2. Provider correctly processes messages with [IMAGE:...] markers
//! 3. Request is sent to API with proper image_url format
//!
//! Requires:
//! - Live provider OAuth credentials (OpenAI Codex or Gemini)
//! - Test image at /tmp/test_vision.png
//!
//! Run manually: `cargo test provider_vision -- --ignored --nocapture`
use anyhow::Result;
use zeroclaw::providers::{ChatMessage, ChatRequest, ProviderRuntimeOptions};
/// Tests that provider supports vision input.
///
/// This test:
/// 1. Creates provider via factory (tries OpenAI Codex, falls back to Gemini)
/// 2. Verifies vision capability is reported
/// 3. Sends a message with [IMAGE:...] marker
/// 4. Verifies request succeeds without capability error
#[tokio::test]
#[ignore = "requires live provider OAuth credentials"]
async fn provider_vision_support() -> Result<()> {
// Use Gemini provider (OpenAI Codex is rate-limited until 21 Feb)
println!("Creating Gemini provider...");
let provider = zeroclaw::providers::create_provider("gemini", None)?;
let provider_name = "gemini";
let model = "gemini-2.5-pro";
println!("✓ Created {} provider", provider_name);
// Warmup provider (for OAuth token refresh if needed)
println!("Warming up provider...");
provider.warmup().await?;
println!("✓ Provider warmed up");
// Verify vision capability
let capabilities = provider.capabilities();
println!(
"Provider {} capabilities: vision={}",
provider_name, capabilities.vision
);
if !capabilities.vision {
anyhow::bail!(
"❌ {} provider does not report vision capability! \
Check that provider's capabilities() returns vision=true",
provider_name
);
}
println!("✓ Provider {} reports vision=true", provider_name);
// Prepare test image path
let test_image = "/tmp/test_vision.png";
if !std::path::Path::new(test_image).exists() {
eprintln!("⚠️ Test image not found at {}", test_image);
eprintln!("Creating minimal 1x1 PNG...");
// Create minimal PNG if missing
use base64::{engine::general_purpose, Engine as _};
let png_data = general_purpose::STANDARD.decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)?;
std::fs::write(test_image, png_data)?;
println!("✓ Created test image at {}", test_image);
}
// Prepare message with image marker
let user_message = format!("What is in this image? [IMAGE:{}]", test_image);
println!("Sending message with image marker...");
println!("Message: {}", user_message);
// Build chat request
let messages = vec![
ChatMessage::system("You are a helpful assistant that can analyze images."),
ChatMessage::user(user_message.clone()),
];
let request = ChatRequest {
messages: &messages,
tools: None,
};
// Send request to provider
println!("Using model: {}", model);
let result = provider.chat(request, model, 0.7).await;
match result {
Ok(response) => {
println!("✓ Request succeeded!");
if let Some(text) = response.text {
println!("Response text: {}", text);
}
println!("Tool calls: {}", response.tool_calls.len());
// Success: provider accepted vision input
println!("\n{} vision support is working!", provider_name);
Ok(())
}
Err(e) => {
eprintln!("❌ Request failed: {}", e);
// Check if it's the capability error we're testing for
let error_str = e.to_string();
if error_str.contains("provider_capability_error")
|| error_str.contains("does not support vision")
{
eprintln!("\n⚠️ CAPABILITY ERROR DETECTED!");
eprintln!("This means the agent loop is still blocking vision input.");
eprintln!("Possible causes:");
eprintln!(" 1. Service binary not rebuilt (check timestamp)");
eprintln!(" 2. Service not restarted with new binary");
eprintln!(" 3. Provider factory returning wrong implementation");
anyhow::bail!("Vision capability check failed in agent loop");
}
// Other errors (API error, auth, etc) are also failures but different nature
eprintln!("\n⚠️ Request failed with non-capability error");
eprintln!("This might be:");
eprintln!(" - API authentication issue");
eprintln!(" - Network error");
eprintln!(" - API format rejection");
Err(e)
}
}
}
/// Tests that OpenAI Codex second profile supports vision input.
///
/// This test:
/// 1. Creates OpenAI Codex provider with "second" profile override
/// 2. Verifies vision capability is reported
/// 3. Sends a message with [IMAGE:...] marker
/// 4. Verifies request succeeds without capability error
#[tokio::test]
#[ignore = "requires live OpenAI Codex OAuth credentials (second profile)"]
async fn openai_codex_second_vision_support() -> Result<()> {
println!("Creating OpenAI Codex provider with second profile...");
// Create provider with profile override
let opts = ProviderRuntimeOptions {
auth_profile_override: Some("second".to_string()),
provider_api_url: None,
zeroclaw_dir: None,
secrets_encrypt: false,
reasoning_enabled: None,
reasoning_effort: None,
provider_timeout_secs: None,
provider_max_tokens: None,
extra_headers: std::collections::HashMap::new(),
api_path: None,
};
let provider = zeroclaw::providers::create_provider_with_options("openai-codex", None, &opts)?;
let provider_name = "openai-codex:second";
let model = "gpt-5.3-codex";
println!("✓ Created {} provider", provider_name);
// Verify vision capability
let capabilities = provider.capabilities();
println!(
"Provider {} capabilities: vision={}",
provider_name, capabilities.vision
);
if !capabilities.vision {
anyhow::bail!(
"❌ {} provider does not report vision capability! \
Check that provider's capabilities() returns vision=true",
provider_name
);
}
println!("✓ Provider {} reports vision=true", provider_name);
// Prepare test image path
let test_image = "/tmp/test_vision.png";
if !std::path::Path::new(test_image).exists() {
eprintln!("⚠️ Test image not found at {}", test_image);
eprintln!("Creating minimal 1x1 PNG...");
// Create minimal PNG if missing
use base64::{engine::general_purpose, Engine as _};
let png_data = general_purpose::STANDARD.decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)?;
std::fs::write(test_image, png_data)?;
println!("✓ Created test image at {}", test_image);
}
// Prepare message with image marker
let user_message = format!("What is in this image? [IMAGE:{}]", test_image);
println!("Sending message with image marker...");
println!("Message: {}", user_message);
// Build chat request
let messages = vec![
ChatMessage::system("You are a helpful assistant that can analyze images."),
ChatMessage::user(user_message.clone()),
];
let request = ChatRequest {
messages: &messages,
tools: None,
};
// Send request to provider
println!("Using model: {}", model);
let result = provider.chat(request, model, 0.7).await;
match result {
Ok(response) => {
println!("✓ Request succeeded!");
if let Some(text) = response.text {
println!("Response text: {}", text);
}
println!("Tool calls: {}", response.tool_calls.len());
// Success: provider accepted vision input
println!("\n{} vision support is working!", provider_name);
Ok(())
}
Err(e) => {
eprintln!("❌ Request failed: {}", e);
// Check if it's the capability error we're testing for
let error_str = e.to_string();
if error_str.contains("provider_capability_error")
|| error_str.contains("does not support vision")
{
eprintln!("\n⚠️ CAPABILITY ERROR DETECTED!");
eprintln!("This means the agent loop is still blocking vision input.");
anyhow::bail!("Vision capability check failed in agent loop");
}
// Check if it's rate limit
if error_str.contains("429")
|| error_str.contains("rate")
|| error_str.contains("limit")
{
eprintln!("\n⚠️ RATE LIMITED!");
eprintln!("Second OpenAI Codex profile is also rate-limited.");
eprintln!("This is OK - it means both profiles share the same quota.");
// Don't fail the test - rate limit is expected
return Ok(());
}
// Other errors (API error, auth, etc) are also failures but different nature
eprintln!("\n⚠️ Request failed with non-capability error");
eprintln!("This might be:");
eprintln!(" - API authentication issue");
eprintln!(" - Network error");
eprintln!(" - API format rejection");
Err(e)
}
}
}

View File

@@ -0,0 +1,50 @@
//! Consolidated live provider tests.
//!
//! All tests in this module require real external API credentials and are
//! marked with `#[ignore]`. Run with: `cargo test --test live -- --ignored`
use zeroclaw::providers::traits::{ChatMessage, Provider};
use zeroclaw::providers::ProviderRuntimeOptions;
/// Sends a real multi-turn conversation to OpenAI Codex and verifies
/// the model retains context from earlier messages.
///
/// Requires valid OAuth credentials in `~/.zeroclaw/`.
/// Run manually: `cargo test e2e_live_openai_codex_multi_turn -- --ignored`
#[tokio::test]
#[ignore = "requires live OpenAI Codex OAuth credentials"]
async fn e2e_live_openai_codex_multi_turn() {
use zeroclaw::providers::openai_codex::OpenAiCodexProvider;
let provider = OpenAiCodexProvider::new(&ProviderRuntimeOptions::default(), None).unwrap();
let model = "gpt-5.3-codex";
// Turn 1: establish a fact
let messages_turn1 = vec![
ChatMessage::system("You are a concise assistant. Reply in one short sentence."),
ChatMessage::user("The secret word is \"zephyr\". Just confirm you noted it."),
];
let response1 = provider
.chat_with_history(&messages_turn1, model, 0.0)
.await;
assert!(response1.is_ok(), "Turn 1 failed: {:?}", response1.err());
let r1 = response1.unwrap();
assert!(!r1.is_empty(), "Turn 1 returned empty response");
// Turn 2: ask the model to recall the fact
let messages_turn2 = vec![
ChatMessage::system("You are a concise assistant. Reply in one short sentence."),
ChatMessage::user("The secret word is \"zephyr\". Just confirm you noted it."),
ChatMessage::assistant(&r1),
ChatMessage::user("What is the secret word?"),
];
let response2 = provider
.chat_with_history(&messages_turn2, model, 0.0)
.await;
assert!(response2.is_ok(), "Turn 2 failed: {:?}", response2.err());
let r2 = response2.unwrap().to_lowercase();
assert!(
r2.contains("zephyr"),
"Model should recall 'zephyr' from history, got: {r2}",
);
}

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
Test message generator for Telegram integration testing.
Generates messages of various lengths for testing message splitting.
"""
import sys
def generate_short_message():
"""Generate a short message (< 100 chars)"""
return "Hello! This is a short test message."
def generate_medium_message():
"""Generate a medium message (~ 1000 chars)"""
return "This is a medium-length test message. " * 25
def generate_long_message():
"""Generate a long message (~ 5000 chars, > 4096 limit)"""
return "This is a very long test message that will be split into multiple chunks. " * 70
def generate_exact_limit_message():
"""Generate a message exactly at 4096 char limit"""
base = "x" * 4096
return base
def generate_over_limit_message():
"""Generate a message just over the 4096 char limit"""
return "x" * 4200
def generate_multi_chunk_message():
"""Generate a message that requires 3+ chunks"""
return "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 250
def generate_newline_message():
"""Generate a message with many newlines (tests newline splitting)"""
return "Line of text\n" * 400
def generate_word_boundary_message():
"""Generate a message with clear word boundaries"""
return "word " * 1000
def print_message_info(message, name):
"""Print information about a message"""
print(f"\n{'='*60}")
print(f"{name}")
print(f"{'='*60}")
print(f"Length: {len(message)} characters")
print(f"Will split: {'Yes' if len(message) > 4096 else 'No'}")
if len(message) > 4096:
chunks = (len(message) + 4095) // 4096
print(f"Estimated chunks: {chunks}")
print(f"{'='*60}")
print(message[:200] + "..." if len(message) > 200 else message)
print(f"{'='*60}\n")
def main():
if len(sys.argv) > 1:
test_type = sys.argv[1].lower()
else:
print("Usage: python3 generate_test_messages.py [type]")
print("\nAvailable types:")
print(" short - Short message (< 100 chars)")
print(" medium - Medium message (~1000 chars)")
print(" long - Long message (~5000 chars, requires splitting)")
print(" exact - Exactly 4096 chars")
print(" over - Just over 4096 chars")
print(" multi - Very long (3+ chunks)")
print(" newline - Many newlines (tests line splitting)")
print(" word - Clear word boundaries")
print(" all - Show info for all types")
print("\nExample:")
print(" python3 generate_test_messages.py long")
sys.exit(1)
messages = {
'short': ('Short Message', generate_short_message()),
'medium': ('Medium Message', generate_medium_message()),
'long': ('Long Message', generate_long_message()),
'exact': ('Exact Limit (4096)', generate_exact_limit_message()),
'over': ('Just Over Limit', generate_over_limit_message()),
'multi': ('Multi-Chunk Message', generate_multi_chunk_message()),
'newline': ('Newline Test', generate_newline_message()),
'word': ('Word Boundary Test', generate_word_boundary_message()),
}
if test_type == 'all':
for name, msg in messages.values():
print_message_info(msg, name)
elif test_type in messages:
name, msg = messages[test_type]
# Just print the message for piping to Telegram
print(msg)
else:
print(f"Error: Unknown type '{test_type}'")
print("Run without arguments to see available types.")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Quick smoke test for Telegram integration
# Run this before committing code changes
set -e
echo "🔥 Quick Telegram Smoke Test"
echo ""
# Test 1: Compile check
echo -n "1. Compiling... "
cargo build --release --quiet 2>&1 && echo "✓" || { echo "✗ FAILED"; exit 1; }
# Test 2: Unit tests
echo -n "2. Running tests... "
cargo test telegram_split --lib --quiet 2>&1 && echo "✓" || { echo "✗ FAILED"; exit 1; }
# Test 3: Health check
echo -n "3. Health check... "
timeout 7 target/release/zeroclaw channel doctor &>/dev/null && echo "✓" || echo "⚠ (configure bot first)"
# Test 4: File checks
echo -n "4. Code structure... "
grep -q "TELEGRAM_MAX_MESSAGE_LENGTH" src/channels/telegram.rs && \
grep -q "split_message_for_telegram" src/channels/telegram.rs && \
grep -q "tokio::time::timeout" src/channels/telegram.rs && \
echo "✓" || { echo "✗ FAILED"; exit 1; }
echo ""
echo "✅ Quick tests passed! Run ./tests/telegram/test_telegram_integration.sh for full suite."

View File

@@ -0,0 +1,362 @@
#!/bin/bash
# ZeroClaw Telegram Integration Test Suite
# Automated testing script for Telegram channel functionality
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test counters
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Helper functions
print_header() {
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
}
print_test() {
TOTAL_TESTS=$((TOTAL_TESTS + 1))
echo -e "${YELLOW}Test $TOTAL_TESTS:${NC} $1"
}
pass() {
PASSED_TESTS=$((PASSED_TESTS + 1))
echo -e "${GREEN}✓ PASS:${NC} $1\n"
}
fail() {
FAILED_TESTS=$((FAILED_TESTS + 1))
echo -e "${RED}✗ FAIL:${NC} $1\n"
}
warn() {
echo -e "${YELLOW}⚠ WARNING:${NC} $1\n"
}
# Banner
clear
cat << "EOF"
⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
███████╗███████╗██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗
╚══███╔╝██╔════╝██╔══██╗██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║
███╔╝ █████╗ ██████╔╝██║ ██║██║ ██║ ███████║██║ █╗ ██║
███╔╝ ██╔══╝ ██╔══██╗██║ ██║██║ ██║ ██╔══██║██║███╗██║
███████╗███████╗██║ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝
╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝
🧪 TELEGRAM INTEGRATION TEST SUITE 🧪
⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
EOF
echo -e "\n${BLUE}Started at:${NC} $(date)"
echo -e "${BLUE}Working directory:${NC} $(pwd)\n"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 1: Code Quality Tests
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print_header "Phase 1: Code Quality Tests"
# Test 1: Cargo test compilation
print_test "Compiling test suite"
if cargo test --lib --no-run &>/dev/null; then
pass "Test suite compiles successfully"
else
fail "Test suite compilation failed"
exit 1
fi
# Test 2: Unit tests
print_test "Running Telegram unit tests"
TEST_OUTPUT=$(cargo test telegram --lib 2>&1)
if echo "$TEST_OUTPUT" | grep -q "test result: ok"; then
PASSED_COUNT=$(echo "$TEST_OUTPUT" | grep -oP '\d+(?= passed)' | head -1)
pass "All Telegram unit tests passed ($PASSED_COUNT tests)"
else
fail "Some unit tests failed"
echo "$TEST_OUTPUT" | grep "FAILED\|error"
fi
# Test 3: Message splitting tests specifically
print_test "Verifying message splitting tests"
if cargo test telegram_split --lib --quiet 2>&1 | grep -q "8 passed"; then
pass "All 8 message splitting tests passed"
else
fail "Message splitting tests incomplete"
fi
# Test 4: Clippy linting
print_test "Running Clippy lint checks"
if cargo clippy --all-targets --quiet 2>&1 | grep -qv "error:"; then
pass "No clippy errors found"
else
CLIPPY_ERRORS=$(cargo clippy --all-targets 2>&1 | grep "error:" | wc -l)
fail "Clippy found $CLIPPY_ERRORS error(s)"
fi
# Test 5: Code formatting
print_test "Checking code formatting"
if cargo fmt --check &>/dev/null; then
pass "Code is properly formatted"
else
warn "Code formatting issues found (run 'cargo fmt' to fix)"
fi
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 2: Build Tests
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print_header "Phase 2: Build Tests"
# Test 6: Debug build
print_test "Debug build"
if cargo build --quiet 2>&1; then
pass "Debug build successful"
else
fail "Debug build failed"
fi
# Test 7: Release build
print_test "Release build with optimizations"
START_TIME=$(date +%s)
if cargo build --release --quiet 2>&1; then
END_TIME=$(date +%s)
BUILD_TIME=$((END_TIME - START_TIME))
pass "Release build successful (${BUILD_TIME}s)"
else
fail "Release build failed"
fi
# Test 8: Binary size check
print_test "Binary size verification"
if [ -f "target/release/zeroclaw" ]; then
BINARY_SIZE=$(ls -lh target/release/zeroclaw | awk '{print $5}')
SIZE_BYTES=$(stat -f%z target/release/zeroclaw 2>/dev/null || stat -c%s target/release/zeroclaw)
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
if [ $SIZE_MB -le 10 ]; then
pass "Binary size is optimal: $BINARY_SIZE (${SIZE_MB}MB)"
else
warn "Binary size is larger than expected: $BINARY_SIZE (${SIZE_MB}MB)"
fi
else
fail "Release binary not found"
fi
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 3: Configuration Tests
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print_header "Phase 3: Configuration Tests"
# Test 9: Config file existence
print_test "Configuration file check"
CONFIG_PATH="$HOME/.zeroclaw/config.toml"
if [ -f "$CONFIG_PATH" ]; then
pass "Config file exists at $CONFIG_PATH"
# Test 10: Telegram config
print_test "Telegram configuration check"
if grep -q "\[channels_config.telegram\]" "$CONFIG_PATH"; then
pass "Telegram configuration found"
# Test 11: Bot token configured
print_test "Bot token validation"
if grep -q "bot_token = \"" "$CONFIG_PATH"; then
pass "Bot token is configured"
else
warn "Bot token not set - integration tests will be skipped"
fi
# Test 12: Allowlist configured
print_test "User allowlist validation"
if grep -q "allowed_users = \[" "$CONFIG_PATH"; then
pass "User allowlist is configured"
else
warn "User allowlist not set"
fi
else
warn "Telegram not configured - run 'zeroclaw onboard' first"
fi
else
warn "No config file found - run 'zeroclaw onboard' first"
fi
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 4: Health Check Tests
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print_header "Phase 4: Health Check Tests"
# Test 13: Health check timeout
print_test "Health check timeout (should complete in <5s)"
START_TIME=$(date +%s)
HEALTH_OUTPUT=$(timeout 10 target/release/zeroclaw channel doctor 2>&1 || true)
END_TIME=$(date +%s)
HEALTH_TIME=$((END_TIME - START_TIME))
if [ $HEALTH_TIME -le 6 ]; then
pass "Health check completed in ${HEALTH_TIME}s (timeout fix working)"
else
warn "Health check took ${HEALTH_TIME}s (expected <5s)"
fi
# Test 14: Telegram connectivity
print_test "Telegram API connectivity"
if echo "$HEALTH_OUTPUT" | grep -q "Telegram.*healthy"; then
pass "Telegram channel is healthy"
elif echo "$HEALTH_OUTPUT" | grep -q "Telegram.*unhealthy"; then
warn "Telegram channel is unhealthy - check bot token"
elif echo "$HEALTH_OUTPUT" | grep -q "Telegram.*timed out"; then
warn "Telegram health check timed out - network issue?"
else
warn "Could not determine Telegram health status"
fi
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 5: Feature Validation Tests
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print_header "Phase 5: Feature Validation Tests"
# Test 15: Message splitting function exists
print_test "Message splitting function implementation"
if grep -q "fn split_message_for_telegram" src/channels/telegram.rs; then
pass "Message splitting function implemented"
else
fail "Message splitting function not found"
fi
# Test 16: Message length constant
print_test "Telegram message length constant"
if grep -q "const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096" src/channels/telegram.rs; then
pass "TELEGRAM_MAX_MESSAGE_LENGTH constant defined correctly"
else
fail "Message length constant missing or incorrect"
fi
# Test 17: Timeout implementation
print_test "Health check timeout implementation"
if grep -q "tokio::time::timeout" src/channels/telegram.rs; then
pass "Timeout mechanism implemented in health_check"
else
fail "Timeout not implemented in health_check"
fi
# Test 18: chat_id validation
print_test "chat_id validation implementation"
if grep -q "let Some(chat_id) = chat_id else" src/channels/telegram.rs; then
pass "chat_id validation implemented"
else
fail "chat_id validation missing"
fi
# Test 19: Duration import
print_test "std::time::Duration import"
if grep -q "use std::time::Duration" src/channels/telegram.rs; then
pass "Duration import added"
else
fail "Duration import missing"
fi
# Test 20: Continuation markers
print_test "Multi-part message markers"
if grep -q "(continues...)" src/channels/telegram.rs && grep -q "(continued)" src/channels/telegram.rs; then
pass "Continuation markers implemented for split messages"
else
fail "Continuation markers missing"
fi
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Phase 6: Integration Test Preparation
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print_header "Phase 6: Manual Integration Tests"
echo -e "${BLUE}The following tests require manual interaction:${NC}\n"
cat << 'EOF'
📱 Manual Test Checklist:
1. [ ] Start the channel:
zeroclaw channel start
2. [ ] Send a short message to your bot in Telegram:
"Hello bot!"
✓ Verify: Bot responds within 3 seconds
3. [ ] Send a long message (>4096 characters):
python3 -c 'print("test " * 1000)'
✓ Verify: Message is split into chunks
✓ Verify: Chunks have (continues...) and (continued) markers
✓ Verify: All chunks arrive in order
4. [ ] Test unauthorized access:
- Edit config: allowed_users = ["999999999"]
- Send a message
✓ Verify: Warning log appears
✓ Verify: Message is ignored
- Restore correct user ID
5. [ ] Test rapid messages (10 messages in 5 seconds):
✓ Verify: All messages are processed
✓ Verify: No rate limit errors
✓ Verify: Responses have delays
6. [ ] Check logs for errors:
RUST_LOG=debug zeroclaw channel start
✓ Verify: No unexpected errors
✓ Verify: "missing chat_id" appears for malformed messages
✓ Verify: Health check logs show "timed out" if needed
EOF
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Test Summary
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print_header "Test Summary"
echo -e "${BLUE}Total Tests:${NC} $TOTAL_TESTS"
echo -e "${GREEN}Passed:${NC} $PASSED_TESTS"
echo -e "${RED}Failed:${NC} $FAILED_TESTS"
echo -e "${YELLOW}Warnings:${NC} $((TOTAL_TESTS - PASSED_TESTS - FAILED_TESTS))"
PASS_RATE=$((PASSED_TESTS * 100 / TOTAL_TESTS))
echo -e "\n${BLUE}Pass Rate:${NC} ${PASS_RATE}%"
if [ $FAILED_TESTS -eq 0 ]; then
echo -e "\n${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}✓ ALL AUTOMATED TESTS PASSED! 🎉${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "${BLUE}Next Steps:${NC}"
echo -e "1. Run manual integration tests (see checklist above)"
echo -e "2. Deploy to production when ready"
echo -e "3. Monitor logs for issues\n"
exit 0
else
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${RED}✗ SOME TESTS FAILED${NC}"
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "${BLUE}Troubleshooting:${NC}"
echo -e "1. Review failed tests above"
echo -e "2. Run: cargo test telegram --lib -- --nocapture"
echo -e "3. Check: cargo clippy --all-targets"
echo -e "4. Fix issues and re-run this script\n"
exit 1
fi

View File

@@ -0,0 +1,352 @@
# Telegram Integration Testing Guide
This guide covers testing the Telegram channel integration for ZeroClaw.
## 🚀 Quick Start
### Automated Tests
```bash
# Full test suite (20+ tests, ~2 minutes)
./tests/telegram/test_telegram_integration.sh
# Quick smoke test (~10 seconds)
./tests/telegram/quick_test.sh
# Just unit tests
cargo test telegram --lib
```
## 📋 Test Coverage
### Automated Tests (20 tests)
The `test_telegram_integration.sh` script runs:
**Phase 1: Code Quality (5 tests)**
- ✅ Test compilation
- ✅ Unit tests (24 tests)
- ✅ Message splitting tests (8 tests)
- ✅ Clippy linting
- ✅ Code formatting
**Phase 2: Build Tests (3 tests)**
- ✅ Debug build
- ✅ Release build
- ✅ Binary size verification (<10MB)
**Phase 3: Configuration Tests (4 tests)**
- ✅ Config file exists
- ✅ Telegram section configured
- ✅ Bot token set
- ✅ User allowlist configured
**Phase 4: Health Check Tests (2 tests)**
- ✅ Health check timeout (<5s)
- ✅ Telegram API connectivity
**Phase 5: Feature Validation (6 tests)**
- ✅ Message splitting function
- ✅ Message length constant (4096)
- ✅ Timeout implementation
- ✅ chat_id validation
- ✅ Duration import
- ✅ Continuation markers
### Manual Tests (6 tests)
After running automated tests, perform these manual checks:
1. **Basic messaging**
```bash
zeroclaw channel start
```
- Send "Hello bot!" in Telegram
- Verify response within 3 seconds
2. **Long message splitting**
```bash
# Generate 5000+ char message
python3 -c 'print("test " * 1000)'
```
- Paste into Telegram
- Verify: Message split into chunks
- Verify: Markers show `(continues...)` and `(continued)`
- Verify: All chunks arrive in order
3. **Unauthorized user blocking**
```toml
# Edit ~/.zeroclaw/config.toml
allowed_users = ["999999999"]
```
- Send message to bot
- Verify: Warning in logs
- Verify: Message ignored
- Restore correct user ID
4. **Rate limiting**
- Send 10 messages rapidly
- Verify: All processed
- Verify: No "Too Many Requests" errors
- Verify: Responses have delays
5. **Mention-only mode (group chats)**
```toml
# Edit ~/.zeroclaw/config.toml
[channels.telegram]
mention_only = true
```
- Add bot to a group chat
- Send message without @botname mention
- Verify: Bot does not respond
- Send message with @botname mention
- Verify: Bot responds and mention is stripped
- DM/private chat should always work regardless of mention_only
6. **Error logging**
```bash
RUST_LOG=debug zeroclaw channel start
```
- Check for unexpected errors
- Verify proper error handling
6. **Health check timeout**
```bash
time zeroclaw channel doctor
```
- Verify: Completes in <5 seconds
## 🔍 Test Results Interpretation
### Success Criteria
- All 20 automated tests pass ✅
- Health check completes in <5s ✅
- Binary size <10MB ✅
- No clippy warnings ✅
- All manual tests pass ✅
### Common Issues
**Issue: Health check times out**
```
Solution: Check bot token is valid
curl "https://api.telegram.org/bot<TOKEN>/getMe"
```
**Issue: Bot doesn't respond**
```
Solution: Check user allowlist
1. Send message to bot
2. Check logs for user_id
3. Update config: allowed_users = ["YOUR_ID"]
4. Run: zeroclaw onboard --channels-only
```
**Issue: Message splitting not working**
```
Solution: Verify code changes
grep -n "split_message_for_telegram" src/channels/telegram.rs
grep -n "TELEGRAM_MAX_MESSAGE_LENGTH" src/channels/telegram.rs
```
## 🧪 Test Scenarios
### Scenario 1: First-Time Setup
```bash
# 1. Run automated tests
./tests/telegram/test_telegram_integration.sh
# 2. Configure Telegram
zeroclaw onboard
# Select Telegram channel
# Enter bot token (from @BotFather)
# Enter your user ID
# 3. Verify health
zeroclaw channel doctor
# 4. Start channel
zeroclaw channel start
# 5. Send test message in Telegram
```
### Scenario 2: After Code Changes
```bash
# 1. Quick validation
./tests/telegram/quick_test.sh
# 2. Full test suite
./tests/telegram/test_telegram_integration.sh
# 3. Manual smoke test
zeroclaw channel start
# Send message in Telegram
```
### Scenario 3: Production Deployment
```bash
# 1. Full test suite
./tests/telegram/test_telegram_integration.sh
# 2. Load test (optional)
# Send 100 messages rapidly
for i in {1..100}; do
echo "Test message $i" | \
curl -X POST "https://api.telegram.org/bot<TOKEN>/sendMessage" \
-d "chat_id=<CHAT_ID>" \
-d "text=Message $i"
done
# 3. Monitor logs
RUST_LOG=info zeroclaw daemon
# 4. Check metrics
zeroclaw status
```
## 📊 Performance Benchmarks
Expected values after all fixes:
| Metric | Expected | How to Measure |
| ---------------------- | ---------- | -------------------------------- |
| Health check time | <5s | `time zeroclaw channel doctor` |
| First response time | <3s | Time from sending to receiving |
| Message split overhead | <50ms | Check logs for timing |
| Memory usage | <10MB | `ps aux \| grep zeroclaw` |
| Binary size | ~3-4MB | `ls -lh target/release/zeroclaw` |
| Unit test coverage | 61/61 pass | `cargo test telegram --lib` |
## 🐛 Debugging Failed Tests
### Debug Unit Tests
```bash
# Verbose output
cargo test telegram --lib -- --nocapture
# Specific test
cargo test telegram_split_over_limit -- --nocapture
# Show ignored tests
cargo test telegram --lib -- --ignored
```
### Debug Integration Issues
```bash
# Maximum logging
RUST_LOG=trace zeroclaw channel start
# Check Telegram API directly
curl "https://api.telegram.org/bot<TOKEN>/getMe"
curl "https://api.telegram.org/bot<TOKEN>/getUpdates"
# Validate config
cat ~/.zeroclaw/config.toml | grep -A 3 "\[channels_config.telegram\]"
```
### Debug Build Issues
```bash
# Clean build
cargo clean
cargo build --release
# Check dependencies
cargo tree | grep telegram
# Update dependencies
cargo update
```
## 🎯 CI/CD Integration
Add to your CI pipeline:
```yaml
# .github/workflows/test.yml
name: Test Telegram Integration
on: [push, pull_request]
jobs:
test:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Run tests
run: |
cargo test telegram --lib
cargo clippy --all-targets -- -D warnings
- name: Check formatting
run: cargo fmt --check
```
## 📝 Test Checklist
Before merging code:
- [ ] `./tests/telegram/quick_test.sh` passes
- [ ] `./tests/telegram/test_telegram_integration.sh` passes
- [ ] Manual tests completed
- [ ] No new clippy warnings
- [ ] Code is formatted (`cargo fmt`)
- [ ] Documentation updated
- [ ] CHANGELOG.md updated
## 🚨 Emergency Rollback
If tests fail in production:
```bash
# 1. Check git history
git log --oneline src/channels/telegram.rs
# 2. Rollback to previous version
git revert <commit-hash>
# 3. Rebuild
cargo build --release
# 4. Restart service
zeroclaw service restart
# 5. Verify
zeroclaw channel doctor
```
## 📚 Additional Resources
- [Telegram Bot API Documentation](https://core.telegram.org/bots/api)
- [ZeroClaw Main README](../../README.md)
- [Contributing Guide](../../CONTRIBUTING.md)
- [Issue Tracker](https://github.com/zeroclaw-labs/zeroclaw/issues)

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# Test script to verify .dockerignore excludes sensitive paths
# Run: ./tests/manual/test_dockerignore.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
DOCKERIGNORE="$PROJECT_ROOT/.dockerignore"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
PASS=0
FAIL=0
log_pass() {
echo -e "${GREEN}${NC} $1"
PASS=$((PASS + 1))
}
log_fail() {
echo -e "${RED}${NC} $1"
FAIL=$((FAIL + 1))
}
# Test 1: .dockerignore exists
echo "=== Testing .dockerignore ==="
if [[ -f "$DOCKERIGNORE" ]]; then
log_pass ".dockerignore file exists"
else
log_fail ".dockerignore file does not exist"
exit 1
fi
# Test 2: Required exclusions are present
MUST_EXCLUDE=(
".git"
".githooks"
"target"
"docs"
"examples"
"tests"
"*.md"
"*.png"
"*.db"
"*.db-journal"
".DS_Store"
".github"
"deny.toml"
"LICENSE"
".env"
".tmp_*"
)
for pattern in "${MUST_EXCLUDE[@]}"; do
# Use fgrep for literal matching
if grep -Fq "$pattern" "$DOCKERIGNORE" 2>/dev/null; then
log_pass "Excludes: $pattern"
else
log_fail "Missing exclusion: $pattern"
fi
done
# Test 3: Build essentials are NOT excluded
MUST_NOT_EXCLUDE=(
"Cargo.toml"
"Cargo.lock"
"src"
)
for path in "${MUST_NOT_EXCLUDE[@]}"; do
if grep -qE "^${path}$" "$DOCKERIGNORE" 2>/dev/null; then
log_fail "Build essential '$path' is incorrectly excluded"
else
log_pass "Build essential NOT excluded: $path"
fi
done
# Test 4: No syntax errors (basic validation)
while IFS= read -r line; do
# Skip empty lines and comments
[[ -z "$line" || "$line" =~ ^# ]] && continue
# Check for common issues
if [[ "$line" =~ [[:space:]]$ ]]; then
log_fail "Trailing whitespace in pattern: '$line'"
fi
done < "$DOCKERIGNORE"
log_pass "No trailing whitespace in patterns"
# Test 5: Verify Docker build context would be small
echo ""
echo "=== Simulating Docker build context ==="
# Create temp dir and simulate what would be sent
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
# Use rsync with .dockerignore patterns to simulate Docker's behavior
cd "$PROJECT_ROOT"
# Count files that WOULD be sent (excluding .dockerignore patterns)
TOTAL_FILES=$(find . -type f | wc -l | tr -d ' ')
CONTEXT_FILES=$(find . -type f \
! -path './.git/*' \
! -path './target/*' \
! -path './docs/*' \
! -path './examples/*' \
! -path './tests/*' \
! -name '*.md' \
! -name '*.png' \
! -name '*.svg' \
! -name '*.db' \
! -name '*.db-journal' \
! -name '.DS_Store' \
! -path './.github/*' \
! -name 'deny.toml' \
! -name 'LICENSE' \
! -name '.env' \
! -name '.env.*' \
2>/dev/null | wc -l | tr -d ' ')
echo "Total files in repo: $TOTAL_FILES"
echo "Files in Docker context: $CONTEXT_FILES"
if [[ $CONTEXT_FILES -lt $TOTAL_FILES ]]; then
log_pass "Docker context is smaller than full repo ($CONTEXT_FILES < $TOTAL_FILES files)"
else
log_fail "Docker context is not being reduced"
fi
# Test 6: Verify critical security files would be excluded
echo ""
echo "=== Security checks ==="
# Check if .git would be excluded
if [[ -d "$PROJECT_ROOT/.git" ]]; then
if grep -q "^\.git$" "$DOCKERIGNORE"; then
log_pass ".git directory will be excluded (security)"
else
log_fail ".git directory NOT excluded - SECURITY RISK"
fi
fi
# Check if any .db files exist and would be excluded
DB_FILES=$(find "$PROJECT_ROOT" -name "*.db" -type f 2>/dev/null | head -5)
if [[ -n "$DB_FILES" ]]; then
if grep -q "^\*\.db$" "$DOCKERIGNORE"; then
log_pass "*.db files will be excluded (security)"
else
log_fail "*.db files NOT excluded - SECURITY RISK"
fi
fi
# Summary
echo ""
echo "=== Summary ==="
echo -e "Passed: ${GREEN}$PASS${NC}"
echo -e "Failed: ${RED}$FAIL${NC}"
if [[ $FAIL -gt 0 ]]; then
echo -e "${RED}FAILED${NC}: $FAIL tests failed"
exit 1
else
echo -e "${GREEN}PASSED${NC}: All tests passed"
exit 0
fi

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -uo pipefail
config_dir="$1"
bin_path="$2"
env ZEROCLAW_CONFIG_DIR="$config_dir" "$bin_path" onboard
status=$?
printf '\nEXIT_STATUS=%s\n' "$status"
sleep 5

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
BIN_PATH="${1:-$ROOT_DIR/target/debug/zeroclaw}"
TMP_ROOT="/tmp/zeroclaw-tmux-onboard-$$"
cleanup() {
tmux kill-session -t "zc_full_$$_custom" >/dev/null 2>&1 || true
tmux kill-session -t "zc_update_$$_synthetic" >/dev/null 2>&1 || true
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux is required for this regression test" >&2
exit 1
fi
if [[ ! -x "$BIN_PATH" ]]; then
echo "Building zeroclaw..."
cargo build --bin zeroclaw >/dev/null
fi
mkdir -p "$TMP_ROOT"
start_onboard_session() {
local session="$1"
local config_dir="$2"
tmux kill-session -t "$session" >/dev/null 2>&1 || true
tmux new-session -d -x 240 -y 60 -s "$session" \
"bash \"$ROOT_DIR/tests/manual/tmux/onboard_wrapper.sh\" \"$config_dir\" \"$BIN_PATH\""
sleep 1
}
paste_value() {
local session="$1"
local buffer_name="$2"
local value="$3"
tmux set-buffer -b "$buffer_name" "$value"
tmux paste-buffer -t "$session":0.0 -b "$buffer_name" -p
}
send_enter() {
local session="$1"
tmux send-keys -t "$session":0.0 Enter
}
send_key() {
local session="$1"
local key="$2"
tmux send-keys -t "$session":0.0 "$key"
}
capture_recent() {
local session="$1"
tmux capture-pane -p -S -80 -t "$session":0.0
}
assert_prompt_value_exact() {
local session="$1"
local prompt="$2"
local value="$3"
local label="$4"
local line
line="$(
capture_recent "$session" |
awk -v prompt="$prompt" 'index($0, prompt) { line = $0 } END { if (line != "") print line; else exit 1 }'
)"
local actual="${line#*"$prompt"}"
if [[ "$actual" != "$value" ]]; then
echo "Unexpected tmux paste rendering for $label" >&2
echo "Prompt: $prompt" >&2
echo "Expected: $value" >&2
echo "Actual line: $line" >&2
exit 1
fi
}
run_full_custom_provider_flow() {
local root="$TMP_ROOT/full"
local config_dir="$root/config"
local workspace_path="$root/ws"
local session="zc_full_$$_custom"
local base_url="https://e.invalid/v1"
local api_key="sk-full-a1b2"
local model="full-model-a1"
mkdir -p "$root"
start_onboard_session "$session" "$config_dir"
send_key "$session" n
sleep 1
paste_value "$session" zc_full_workspace "$workspace_path"
sleep 1
assert_prompt_value_exact "$session" " Enter workspace path: " "$workspace_path" "custom workspace path"
send_enter "$session"
sleep 1
for _ in 1 2 3 4 5; do
send_key "$session" Down
done
send_enter "$session"
sleep 1
paste_value "$session" zc_full_base_url "$base_url"
sleep 1
assert_prompt_value_exact \
"$session" \
" API base URL (e.g. http://localhost:1234 or https://my-api.com): " \
"$base_url" \
"custom provider base URL"
send_enter "$session"
sleep 1
paste_value "$session" zc_full_api_key "$api_key"
sleep 1
assert_prompt_value_exact \
"$session" \
" API key (or Enter to skip if not needed): " \
"$api_key" \
"custom provider API key"
send_enter "$session"
sleep 1
paste_value "$session" zc_full_model "$model"
sleep 1
assert_prompt_value_exact \
"$session" \
" Model name (e.g. llama3, gpt-4o, mistral) [default]: " \
"$model" \
"custom provider model"
send_enter "$session"
sleep 1
}
run_update_custom_model_flow() {
local root="$TMP_ROOT/update"
local config_dir="$root/config"
local session="zc_update_$$_synthetic"
local api_key="sk-synth-a1b2"
local model="synthetic-manual-a1"
mkdir -p "$root"
env ZEROCLAW_CONFIG_DIR="$config_dir" \
"$BIN_PATH" onboard --provider openrouter --api-key seed-key --model openai/gpt-5-mini --force >/dev/null
start_onboard_session "$session" "$config_dir"
send_enter "$session"
sleep 1
send_enter "$session"
sleep 1
for _ in 1 2 3; do
send_key "$session" Down
done
send_enter "$session"
sleep 1
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14; do
send_key "$session" Down
done
send_enter "$session"
sleep 1
paste_value "$session" zc_update_api_key "$api_key"
sleep 1
assert_prompt_value_exact \
"$session" \
" Paste your API key (or press Enter to skip): " \
"$api_key" \
"provider-only API key"
send_enter "$session"
sleep 1
send_key "$session" Down
send_enter "$session"
sleep 1
paste_value "$session" zc_update_model "$model"
sleep 1
assert_prompt_value_exact \
"$session" \
" Enter custom model ID [anthropic/claude-sonnet-4.6]: " \
"$model" \
"custom model ID"
send_enter "$session"
sleep 1
}
run_full_custom_provider_flow
run_update_custom_model_flow
echo "tmux onboarding provider input paths passed"

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)
}
}

View File

@@ -0,0 +1,149 @@
//! System-level tests — full agent orchestration with real components.
//!
//! These tests wire ALL internal components together:
//! MockProvider → Agent → Tools → Memory → Agent response
//!
//! Unlike integration tests, system tests use real memory backends (SQLite)
//! and verify end-to-end data flow across component boundaries.
use crate::support::helpers::{build_agent_with_sqlite_memory, text_response, tool_response};
use crate::support::{CountingTool, EchoTool, MockProvider, RecordingTool};
use zeroclaw::providers::ToolCall;
// ═════════════════════════════════════════════════════════════════════════════
// Full-stack system tests
// ═════════════════════════════════════════════════════════════════════════════
/// Simplest system test: inject message → MockProvider returns text → verify response.
#[tokio::test]
async fn system_simple_text_response() {
let provider = Box::new(MockProvider::new(vec![text_response(
"System test response",
)]));
let temp_dir = tempfile::tempdir().unwrap();
let mut agent =
build_agent_with_sqlite_memory(provider, vec![Box::new(EchoTool)], temp_dir.path());
let response = agent.turn("hello system").await.unwrap();
assert_eq!(response, "System test response");
}
/// Full tool execution flow: message → provider requests tool → tool executes →
/// result fed back to provider → final response.
#[tokio::test]
async fn system_tool_execution_flow() {
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: r#"{"message": "system echo test"}"#.into(),
}]),
text_response("Echo returned: system echo test"),
]));
let temp_dir = tempfile::tempdir().unwrap();
let mut agent =
build_agent_with_sqlite_memory(provider, vec![Box::new(EchoTool)], temp_dir.path());
let response = agent.turn("run echo").await.unwrap();
assert!(
!response.is_empty(),
"Expected response after tool execution flow"
);
}
/// Multi-turn conversation with real SQLite memory — verify history accumulation.
#[tokio::test]
async fn system_multi_turn_conversation() {
let provider = Box::new(MockProvider::new(vec![
text_response("First system response"),
text_response("Second system response"),
text_response("Third system response"),
]));
let temp_dir = tempfile::tempdir().unwrap();
let mut agent =
build_agent_with_sqlite_memory(provider, vec![Box::new(EchoTool)], temp_dir.path());
let r1 = agent.turn("turn 1").await.unwrap();
assert_eq!(r1, "First system response");
let r2 = agent.turn("turn 2").await.unwrap();
assert_eq!(r2, "Second system response");
let r3 = agent.turn("turn 3").await.unwrap();
assert_eq!(r3, "Third system response");
// Verify history accumulated across turns
let history = agent.history();
// system + 3*(user + assistant) = 7
assert_eq!(history.len(), 7, "History should contain 7 messages");
}
/// Tool execution is recorded and arguments are passed correctly.
#[tokio::test]
async fn system_tool_arguments_passed_correctly() {
let (recording_tool, calls) = RecordingTool::new("recorder");
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![ToolCall {
id: "tc1".into(),
name: "recorder".into(),
arguments: r#"{"input": "test_value_42"}"#.into(),
}]),
text_response("Tool recorded the input"),
]));
let temp_dir = tempfile::tempdir().unwrap();
let mut agent =
build_agent_with_sqlite_memory(provider, vec![Box::new(recording_tool)], temp_dir.path());
let response = agent.turn("record something").await.unwrap();
assert!(!response.is_empty());
let recorded_calls = calls.lock().unwrap();
assert_eq!(
recorded_calls.len(),
1,
"Tool should be called exactly once"
);
assert_eq!(
recorded_calls[0]["input"].as_str().unwrap(),
"test_value_42",
"Tool should receive correct arguments"
);
}
/// Multiple tools in a single response — both execute and results feed back.
#[tokio::test]
async fn system_parallel_tool_execution() {
let (counting_tool, count) = CountingTool::new();
let provider = Box::new(MockProvider::new(vec![
tool_response(vec![
ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: r#"{"message": "first"}"#.into(),
},
ToolCall {
id: "tc2".into(),
name: "counter".into(),
arguments: "{}".into(),
},
]),
text_response("Both tools completed"),
]));
let temp_dir = tempfile::tempdir().unwrap();
let mut agent = build_agent_with_sqlite_memory(
provider,
vec![Box::new(EchoTool), Box::new(counting_tool)],
temp_dir.path(),
);
let response = agent.turn("run both tools").await.unwrap();
assert_eq!(response, "Both tools completed");
assert_eq!(*count.lock().unwrap(), 1, "Counter should be invoked once");
}

View File

@@ -0,0 +1 @@
mod full_stack;

View File

@@ -0,0 +1,2 @@
mod component;
mod support;

View File

@@ -0,0 +1,2 @@
mod integration;
mod support;

View File

@@ -0,0 +1,2 @@
mod live;
mod support;

View File

@@ -0,0 +1,2 @@
mod support;
mod system;