PicoBot/src/config/mod.rs

1074 lines
32 KiB
Rust

use chrono::{DateTime, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub providers: HashMap<String, ProviderConfig>,
pub models: HashMap<String, ModelConfig>,
pub agents: HashMap<String, AgentConfig>,
#[serde(default)]
pub gateway: GatewayConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default)]
pub client: ClientConfig,
#[serde(default)]
pub channels: HashMap<String, FeishuChannelConfig>,
#[serde(default)]
pub skills: SkillsConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SkillsConfig {
#[serde(default = "default_skills_enabled")]
pub enabled: bool,
#[serde(default = "default_skills_sources")]
pub sources: Vec<String>,
#[serde(default = "default_skills_max_index_chars")]
pub max_index_chars: usize,
#[serde(default = "default_skills_max_listed")]
pub max_listed_skills: usize,
}
fn default_skills_enabled() -> bool {
true
}
fn default_skills_sources() -> Vec<String> {
vec!["project".to_string(), "user".to_string()]
}
fn default_skills_max_index_chars() -> usize {
4_000
}
fn default_skills_max_listed() -> usize {
32
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
enabled: default_skills_enabled(),
sources: default_skills_sources(),
max_index_chars: default_skills_max_index_chars(),
max_listed_skills: default_skills_max_listed(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FeishuChannelConfig {
#[serde(default)]
pub enabled: bool,
pub app_id: String,
pub app_secret: String,
#[serde(default = "default_allow_from")]
pub allow_from: Vec<String>,
#[serde(default)]
pub agent: String,
#[serde(default = "default_media_dir")]
pub media_dir: String,
/// Emoji type for message reactions (e.g. "THUMBSUP", "OK", "EYES").
#[serde(default = "default_reaction_emoji")]
pub reaction_emoji: String,
}
fn default_allow_from() -> Vec<String> {
vec!["*".to_string()]
}
fn default_media_dir() -> String {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".picobot/media/feishu").to_string_lossy().to_string()
}
fn default_reaction_emoji() -> String {
"Typing".to_string()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
#[serde(rename = "type")]
pub provider_type: String,
pub base_url: String,
pub api_key: String,
#[serde(default)]
pub extra_headers: HashMap<String, String>,
#[serde(default = "default_llm_timeout_secs")]
pub llm_timeout_secs: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ModelConfig {
pub model_id: String,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub max_tokens: Option<u32>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentConfig {
pub provider: String,
pub model: String,
#[serde(default = "default_max_tool_iterations")]
pub max_tool_iterations: usize,
#[serde(default = "default_token_limit")]
pub token_limit: usize,
}
fn default_max_tool_iterations() -> usize {
20
}
fn default_token_limit() -> usize {
128_000
}
fn default_llm_timeout_secs() -> u64 {
120
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GatewayConfig {
#[serde(default = "default_gateway_host")]
pub host: String,
#[serde(default = "default_gateway_port")]
pub port: u16,
#[serde(default)]
pub show_tool_results: bool,
#[serde(default, rename = "session_ttl_hours")]
pub session_ttl_hours: Option<u64>,
#[serde(default = "default_agent_prompt_reinject_every", rename = "agent_prompt_reinject_every")]
pub agent_prompt_reinject_every: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ClientConfig {
#[serde(default = "default_gateway_url")]
pub gateway_url: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SchedulerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_scheduler_tick_resolution_ms")]
pub tick_resolution_ms: u64,
#[serde(default = "default_scheduler_worker_queue_capacity")]
pub worker_queue_capacity: usize,
#[serde(default)]
pub misfire_policy: SchedulerMisfirePolicy,
#[serde(default)]
pub jobs: Vec<SchedulerJobConfig>,
}
pub const BUILTIN_MEMORY_MAINTENANCE_JOB_ID: &str = "builtin.memory_maintenance_daily";
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SchedulerMisfirePolicy {
CatchUp,
#[default]
Skip,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SchedulerJobConfig {
pub id: String,
#[serde(default = "default_scheduler_job_enabled")]
pub enabled: bool,
pub kind: SchedulerJobKind,
#[serde(default)]
pub schedule: Option<SchedulerSchedule>,
#[serde(default)]
pub startup_delay_secs: u64,
#[serde(default)]
pub interval_secs: u64,
#[serde(default)]
pub target: SchedulerJobTarget,
#[serde(default)]
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SchedulerJobKind {
InternalEvent,
OutboundMessage,
AgentTask,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct SchedulerJobTarget {
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
pub chat_id: Option<String>,
#[serde(default)]
pub reply_to: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SchedulerSchedule {
Delay {
seconds: u64,
},
Interval {
seconds: u64,
#[serde(default)]
startup_delay_secs: u64,
},
At {
timestamp: String,
},
Cron {
expression: String,
},
}
impl SchedulerJobConfig {
pub fn resolved_schedule(&self) -> Result<SchedulerSchedule, ConfigError> {
if let Some(schedule) = &self.schedule {
schedule.validate(&self.id)?;
return Ok(schedule.normalized_for_storage());
}
if self.interval_secs > 0 {
return Ok(SchedulerSchedule::Interval {
seconds: self.interval_secs,
startup_delay_secs: self.startup_delay_secs,
});
}
Err(ConfigError::InvalidSchedulerJob(format!(
"scheduler job '{}' requires schedule or interval_secs",
self.id
)))
}
}
impl SchedulerConfig {
pub fn builtin_jobs() -> Vec<SchedulerJobConfig> {
vec![SchedulerJobConfig {
id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(),
enabled: true,
kind: SchedulerJobKind::InternalEvent,
schedule: Some(SchedulerSchedule::Cron {
expression: "0 19 * * *".to_string(),
}),
startup_delay_secs: 0,
interval_secs: 0,
target: SchedulerJobTarget::default(),
payload: serde_json::json!({
"event": "memory_maintenance",
"time_zone": "Asia/Shanghai",
"local_time": "03:00"
}),
}]
}
pub fn effective_jobs(&self) -> Vec<SchedulerJobConfig> {
let mut jobs = Self::builtin_jobs();
for configured in &self.jobs {
if let Some(existing) = jobs.iter_mut().find(|job| job.id == configured.id) {
*existing = configured.clone();
} else {
jobs.push(configured.clone());
}
}
jobs
}
}
impl SchedulerSchedule {
pub fn validate(&self, job_id: &str) -> Result<(), ConfigError> {
match self {
SchedulerSchedule::Delay { seconds } => {
if *seconds == 0 {
return Err(ConfigError::InvalidSchedulerJob(format!(
"scheduler job '{}' delay.seconds must be greater than 0",
job_id
)));
}
}
SchedulerSchedule::Interval { seconds, .. } => {
if *seconds == 0 {
return Err(ConfigError::InvalidSchedulerJob(format!(
"scheduler job '{}' interval.seconds must be greater than 0",
job_id
)));
}
}
SchedulerSchedule::At { timestamp } => {
DateTime::parse_from_rfc3339(timestamp).map_err(|err| {
ConfigError::InvalidSchedulerJob(format!(
"scheduler job '{}' invalid at.timestamp '{}': {}",
job_id, timestamp, err
))
})?;
}
SchedulerSchedule::Cron { expression } => {
parse_scheduler_cron(expression).map_err(|err| {
ConfigError::InvalidSchedulerJob(format!(
"scheduler job '{}' invalid cron.expression '{}': {}",
job_id, expression, err
))
})?;
}
}
Ok(())
}
pub fn is_one_shot(&self) -> bool {
matches!(self, SchedulerSchedule::Delay { .. } | SchedulerSchedule::At { .. })
}
pub fn normalized_for_storage(&self) -> Self {
match self {
SchedulerSchedule::At { timestamp } => {
let parsed = DateTime::parse_from_rfc3339(timestamp)
.map(|value| value.with_timezone(&Utc).to_rfc3339())
.unwrap_or_else(|_| timestamp.clone());
SchedulerSchedule::At { timestamp: parsed }
}
other => other.clone(),
}
}
pub fn display(&self) -> String {
match self {
SchedulerSchedule::Delay { seconds } => format!("delay:{}s", seconds),
SchedulerSchedule::Interval {
seconds,
startup_delay_secs,
} => format!("interval:{}s:start_delay:{}s", seconds, startup_delay_secs),
SchedulerSchedule::At { timestamp } => format!("at:{}", timestamp),
SchedulerSchedule::Cron { expression } => format!("cron:{}", expression),
}
}
}
fn parse_scheduler_cron(expression: &str) -> Result<cron::Schedule, cron::error::Error> {
let normalized = normalize_cron_expression(expression);
cron::Schedule::from_str(&normalized)
}
fn normalize_cron_expression(expression: &str) -> String {
let parts: Vec<&str> = expression.split_whitespace().collect();
if parts.len() == 5 {
format!("0 {}", expression.trim())
} else {
expression.trim().to_string()
}
}
fn default_scheduler_tick_resolution_ms() -> u64 {
1_000
}
fn default_scheduler_worker_queue_capacity() -> usize {
64
}
fn default_scheduler_job_enabled() -> bool {
true
}
fn default_gateway_host() -> String {
"127.0.0.1".to_string()
}
fn default_gateway_port() -> u16 {
19876
}
fn default_gateway_url() -> String {
"ws://127.0.0.1:19876/ws".to_string()
}
fn default_agent_prompt_reinject_every() -> u64 {
100
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
host: default_gateway_host(),
port: default_gateway_port(),
show_tool_results: false,
session_ttl_hours: None,
agent_prompt_reinject_every: default_agent_prompt_reinject_every(),
}
}
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
gateway_url: default_gateway_url(),
}
}
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
enabled: true,
tick_resolution_ms: default_scheduler_tick_resolution_ms(),
worker_queue_capacity: default_scheduler_worker_queue_capacity(),
misfire_policy: SchedulerMisfirePolicy::default(),
jobs: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct LLMProviderConfig {
pub provider_type: String,
pub name: String,
pub base_url: String,
pub api_key: String,
pub extra_headers: HashMap<String, String>,
pub llm_timeout_secs: u64,
pub model_id: String,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub model_extra: HashMap<String, serde_json::Value>,
pub max_tool_iterations: usize,
pub token_limit: usize,
}
fn get_default_config_path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("config.json")
}
impl Config {
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
Self::load_from(Path::new(path))
}
pub fn load_default() -> Result<Self, Box<dyn std::error::Error>> {
let path = get_default_config_path();
Self::load_from(&path)
}
fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
load_env_file()?;
let content = if path.exists() {
tracing::info!(path = %path.display(), "Config loaded");
fs::read_to_string(path)?
} else {
// Fallback to current directory
let fallback = Path::new("config.json");
if fallback.exists() {
tracing::info!(path = %fallback.display(), "Config loaded from fallback path");
fs::read_to_string(fallback)?
} else {
return Err(Box::new(ConfigError::ConfigNotFound(
path.to_string_lossy().to_string(),
)));
}
};
let content = resolve_env_placeholders(&content);
let config: Config = serde_json::from_str(&content)?;
Ok(config)
}
pub fn get_provider_config(&self, agent_name: &str) -> Result<LLMProviderConfig, ConfigError> {
let agent = self.agents.get(agent_name)
.ok_or(ConfigError::AgentNotFound(agent_name.to_string()))?;
let provider = self.providers.get(&agent.provider)
.ok_or(ConfigError::ProviderNotFound(agent.provider.clone()))?;
let model = self.models.get(&agent.model)
.ok_or(ConfigError::ModelNotFound(agent.model.clone()))?;
Ok(LLMProviderConfig {
provider_type: provider.provider_type.clone(),
name: agent.provider.clone(),
base_url: provider.base_url.clone(),
api_key: provider.api_key.clone(),
extra_headers: provider.extra_headers.clone(),
llm_timeout_secs: provider.llm_timeout_secs,
model_id: model.model_id.clone(),
temperature: model.temperature,
max_tokens: model.max_tokens,
model_extra: model.extra.clone(),
max_tool_iterations: agent.max_tool_iterations,
token_limit: agent.token_limit,
})
}
}
#[derive(Debug)]
pub enum ConfigError {
ConfigNotFound(String),
AgentNotFound(String),
ProviderNotFound(String),
ModelNotFound(String),
InvalidSchedulerJob(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::ConfigNotFound(path) => write!(f, "Config file not found: {}. Use CONFIG_PATH env var or place config in ~/.picobot/config.json", path),
ConfigError::AgentNotFound(name) => write!(f, "Agent not found: {}", name),
ConfigError::ProviderNotFound(name) => write!(f, "Provider not found: {}", name),
ConfigError::ModelNotFound(name) => write!(f, "Model not found: {}", name),
ConfigError::InvalidSchedulerJob(message) => write!(f, "Invalid scheduler job: {}", message),
}
}
}
impl std::error::Error for ConfigError {}
fn load_env_file() -> Result<(), Box<dyn std::error::Error>> {
let env_path = Path::new(".env");
if env_path.exists() {
let content = fs::read_to_string(env_path)?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
if !value.is_empty() {
// SAFETY: Setting environment variables for the current process
// is safe as we're only modifying our own process state
unsafe { env::set_var(key, value) };
}
}
}
}
Ok(())
}
fn resolve_env_placeholders(content: &str) -> String {
let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");
re.replace_all(content, |caps: &regex::Captures| {
let var_name = &caps[1];
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
}).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn write_test_config() -> tempfile::NamedTempFile {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
},
"volcengine": {
"type": "openai",
"base_url": "https://example.invalid/volc",
"api_key": "test-key-2",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus",
"temperature": 0.0
},
"doubao-seed-2-0-lite-260215": {
"model_id": "doubao-seed-2-0-lite-260215"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
},
"gateway": {
"host": "0.0.0.0",
"port": 19876,
"agent_prompt_reinject_every": 120
}
}"#,
)
.unwrap();
file
}
#[test]
fn test_config_load() {
let file = write_test_config();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
// Check providers
assert!(config.providers.contains_key("volcengine"));
assert!(config.providers.contains_key("aliyun"));
// Check models
assert!(config.models.contains_key("doubao-seed-2-0-lite-260215"));
assert!(config.models.contains_key("qwen-plus"));
// Check agents
assert!(config.agents.contains_key("default"));
}
#[test]
fn test_get_provider_config() {
let file = write_test_config();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
let provider_config = config.get_provider_config("default").unwrap();
assert_eq!(provider_config.provider_type, "openai");
assert_eq!(provider_config.name, "aliyun");
assert_eq!(provider_config.model_id, "qwen-plus");
assert_eq!(provider_config.temperature, Some(0.0));
assert_eq!(provider_config.llm_timeout_secs, 120);
}
#[test]
fn test_provider_config_loads_custom_llm_timeout() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {},
"llm_timeout_secs": 400
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
let provider_config = config.get_provider_config("default").unwrap();
assert_eq!(provider_config.llm_timeout_secs, 400);
}
#[test]
fn test_default_gateway_config() {
let file = write_test_config();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert_eq!(config.gateway.host, "0.0.0.0");
assert_eq!(config.gateway.port, 19876);
assert!(!config.gateway.show_tool_results);
assert_eq!(config.gateway.agent_prompt_reinject_every, 120);
}
#[test]
fn test_gateway_config_defaults_agent_prompt_reinject_every() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert!(!config.gateway.show_tool_results);
assert_eq!(config.gateway.agent_prompt_reinject_every, 100);
}
#[test]
fn test_gateway_config_can_enable_tool_results() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
},
"gateway": {
"show_tool_results": true
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert!(config.gateway.show_tool_results);
}
#[test]
fn test_scheduler_config_defaults() {
let file = write_test_config();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert!(config.scheduler.enabled);
assert_eq!(config.scheduler.tick_resolution_ms, 1_000);
assert_eq!(config.scheduler.worker_queue_capacity, 64);
assert_eq!(config.scheduler.misfire_policy, SchedulerMisfirePolicy::Skip);
assert!(config.scheduler.jobs.is_empty());
let effective_jobs = config.scheduler.effective_jobs();
assert_eq!(effective_jobs.len(), 1);
assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
assert_eq!(effective_jobs[0].kind, SchedulerJobKind::InternalEvent);
assert_eq!(
effective_jobs[0].resolved_schedule().unwrap(),
SchedulerSchedule::Cron {
expression: "0 19 * * *".to_string(),
}
);
}
#[test]
fn test_scheduler_effective_jobs_allows_builtin_override() {
let mut scheduler = SchedulerConfig::default();
scheduler.jobs.push(SchedulerJobConfig {
id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(),
enabled: false,
kind: SchedulerJobKind::InternalEvent,
schedule: Some(SchedulerSchedule::Cron {
expression: "15 2 * * *".to_string(),
}),
startup_delay_secs: 0,
interval_secs: 0,
target: SchedulerJobTarget::default(),
payload: serde_json::json!({
"event": "memory_maintenance",
"time_zone": "UTC",
"local_time": "02:15"
}),
});
scheduler.jobs.push(SchedulerJobConfig {
id: "custom.reminder".to_string(),
enabled: true,
kind: SchedulerJobKind::InternalEvent,
schedule: Some(SchedulerSchedule::Delay { seconds: 30 }),
startup_delay_secs: 0,
interval_secs: 0,
target: SchedulerJobTarget::default(),
payload: serde_json::json!({"event": "custom"}),
});
let effective_jobs = scheduler.effective_jobs();
assert_eq!(effective_jobs.len(), 2);
assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
assert!(!effective_jobs[0].enabled);
assert_eq!(
effective_jobs[0].resolved_schedule().unwrap(),
SchedulerSchedule::Cron {
expression: "15 2 * * *".to_string(),
}
);
assert_eq!(effective_jobs[1].id, "custom.reminder");
}
#[test]
fn test_scheduler_config_loads_interval_compat_jobs() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
},
"scheduler": {
"enabled": true,
"tick_resolution_ms": 500,
"worker_queue_capacity": 8,
"misfire_policy": "catch_up",
"jobs": [
{
"id": "heartbeat.reminder",
"kind": "outbound_message",
"interval_secs": 60,
"startup_delay_secs": 5,
"target": {
"channel": "feishu",
"chat_id": "oc_demo"
},
"payload": {
"content": "heartbeat"
}
}
]
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert!(config.scheduler.enabled);
assert_eq!(config.scheduler.tick_resolution_ms, 500);
assert_eq!(config.scheduler.worker_queue_capacity, 8);
assert_eq!(config.scheduler.misfire_policy, SchedulerMisfirePolicy::CatchUp);
assert_eq!(config.scheduler.jobs.len(), 1);
let job = &config.scheduler.jobs[0];
assert_eq!(job.id, "heartbeat.reminder");
assert!(job.enabled);
assert_eq!(job.kind, SchedulerJobKind::OutboundMessage);
assert_eq!(job.interval_secs, 60);
assert_eq!(job.startup_delay_secs, 5);
assert_eq!(job.target.channel.as_deref(), Some("feishu"));
assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo"));
assert_eq!(job.payload.get("content").and_then(|value| value.as_str()), Some("heartbeat"));
assert_eq!(job.resolved_schedule().unwrap(), SchedulerSchedule::Interval {
seconds: 60,
startup_delay_secs: 5,
});
}
#[test]
fn test_scheduler_config_loads_schedule_variants() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
},
"scheduler": {
"enabled": true,
"jobs": [
{
"id": "delay.job",
"kind": "internal_event",
"schedule": {
"type": "delay",
"seconds": 30
}
},
{
"id": "at.job",
"kind": "outbound_message",
"schedule": {
"type": "at",
"timestamp": "2026-04-23T09:00:00Z"
},
"target": {
"channel": "feishu",
"chat_id": "oc_demo"
},
"payload": {
"content": "at run"
}
},
{
"id": "cron.job",
"kind": "internal_event",
"schedule": {
"type": "cron",
"expression": "0 9 * * *"
}
}
]
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert_eq!(config.scheduler.jobs.len(), 3);
assert_eq!(
config.scheduler.jobs[0].resolved_schedule().unwrap(),
SchedulerSchedule::Delay { seconds: 30 }
);
assert_eq!(config.scheduler.jobs[0].kind, SchedulerJobKind::InternalEvent);
assert_eq!(
config.scheduler.jobs[1].resolved_schedule().unwrap(),
SchedulerSchedule::At {
timestamp: "2026-04-23T09:00:00+00:00".to_string(),
}
);
assert_eq!(config.scheduler.jobs[1].kind, SchedulerJobKind::OutboundMessage);
assert_eq!(
config.scheduler.jobs[2].resolved_schedule().unwrap(),
SchedulerSchedule::Cron {
expression: "0 9 * * *".to_string(),
}
);
assert_eq!(config.scheduler.jobs[2].kind, SchedulerJobKind::InternalEvent);
}
#[test]
fn test_scheduler_config_loads_agent_task_job() {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
file.path(),
r#"{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test-key",
"extra_headers": {}
}
},
"models": {
"qwen-plus": {
"model_id": "qwen-plus"
}
},
"agents": {
"default": {
"provider": "aliyun",
"model": "qwen-plus"
}
},
"scheduler": {
"enabled": true,
"jobs": [
{
"id": "agent.daily_summary",
"kind": "agent_task",
"schedule": {
"type": "cron",
"expression": "0 9 * * *"
},
"target": {
"channel": "feishu",
"chat_id": "oc_demo"
},
"payload": {
"prompt": "请总结今天待办"
}
}
]
}
}"#,
)
.unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
let job = &config.scheduler.jobs[0];
assert_eq!(job.kind, SchedulerJobKind::AgentTask);
assert_eq!(job.target.channel.as_deref(), Some("feishu"));
assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo"));
assert_eq!(job.payload.get("prompt").and_then(|value| value.as_str()), Some("请总结今天待办"));
}
#[test]
fn test_scheduler_schedule_validation_rejects_invalid_values() {
assert!(SchedulerSchedule::Delay { seconds: 0 }
.validate("delay.job")
.is_err());
assert!(SchedulerSchedule::Interval {
seconds: 0,
startup_delay_secs: 0,
}
.validate("interval.job")
.is_err());
assert!(SchedulerSchedule::At {
timestamp: "bad timestamp".to_string(),
}
.validate("at.job")
.is_err());
assert!(SchedulerSchedule::Cron {
expression: "bad cron".to_string(),
}
.validate("cron.job")
.is_err());
}
}