Skip to main content

zeroclaw_runtime/tools/
cron_add.rs

1use super::cron_common::{
2    AT_DESCRIPTION, CRON_TZ_DESCRIPTION, cron_add_output, deserialize_schedule_arg,
3};
4use crate::cron::{self, DeliveryConfig, JobType, Schedule, SessionTarget};
5use crate::security::SecurityPolicy;
6use async_trait::async_trait;
7use serde_json::json;
8use std::sync::Arc;
9use zeroclaw_api::tool::{Tool, ToolResult};
10use zeroclaw_config::schema::Config;
11
12pub struct CronAddTool {
13    config: Arc<Config>,
14    security: Arc<SecurityPolicy>,
15    /// Owning agent — the alias of the agent whose tool loop registered
16    /// this tool instance. Cron jobs created here are validated against
17    /// this agent's risk profile and run as this agent.
18    agent_alias: String,
19}
20
21impl CronAddTool {
22    pub fn new(
23        config: Arc<Config>,
24        security: Arc<SecurityPolicy>,
25        agent_alias: impl Into<String>,
26    ) -> Self {
27        Self {
28            config,
29            security,
30            agent_alias: agent_alias.into(),
31        }
32    }
33
34    fn plain_string_schedule_error(raw: &str) -> Option<String> {
35        let schedule = raw.trim();
36        if schedule.starts_with('{') {
37            return None;
38        }
39
40        let got = serde_json::to_string(schedule).unwrap_or_else(|_| "\"<invalid>\"".to_string());
41        Some(format!(
42            "Invalid schedule: expected a JSON object with a \"kind\" field, got plain string {got}. \
43             Use one of: {{\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\"}}, \
44             {{\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}}, or \
45             {{\"kind\":\"every\",\"every_ms\":3600000}}"
46        ))
47    }
48
49    fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
50        if !self.security.can_act() {
51            return Some(ToolResult {
52                success: false,
53                output: String::new(),
54                error: Some(format!(
55                    "Security policy: read-only mode, cannot perform '{action}'"
56                )),
57            });
58        }
59
60        if self.security.is_rate_limited() {
61            return Some(ToolResult {
62                success: false,
63                output: String::new(),
64                error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
65            });
66        }
67
68        if !self.security.record_action() {
69            return Some(ToolResult {
70                success: false,
71                output: String::new(),
72                error: Some("Rate limit exceeded: action budget exhausted".to_string()),
73            });
74        }
75
76        None
77    }
78}
79
80#[async_trait]
81impl Tool for CronAddTool {
82    fn name(&self) -> &str {
83        "cron_add"
84    }
85
86    fn description(&self) -> &str {
87        "Create a scheduled cron job (shell or agent) with cron/at/every schedules. \
88         Use job_type='agent' with a prompt to run the AI agent on schedule. \
89         To deliver output to a channel (Discord, Telegram, Slack, Mattermost, Matrix, QQ, Webhook), set \
90         delivery={\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"<channel_id_or_chat_id>\"}. \
91         For webhook deliveries that must thread through the originating conversation, also set \
92         delivery.thread_id=\"<reply_target>\". \
93         This is the preferred tool for sending scheduled/delayed messages to users via channels."
94    }
95
96    fn parameters_schema(&self) -> serde_json::Value {
97        json!({
98            "type": "object",
99            "properties": {
100                "name": {
101                    "type": "string",
102                    "description": "Optional human-readable name for the job"
103                },
104                // NOTE: oneOf is correct for OpenAI-compatible APIs (including OpenRouter).
105                // Gemini does not support oneOf in tool schemas; if Gemini native tool calling
106                // is ever wired up, SchemaCleanr::clean_for_gemini must be applied before
107                // tool specs are sent. See src/tools/schema.rs.
108                "schedule": {
109                    "description": "When to run the job. Exactly one of three forms must be used.",
110                    "oneOf": [
111                        {
112                            "type": "object",
113                            "description": "Cron expression schedule (repeating). Example: {\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\",\"tz\":\"America/New_York\"}",
114                            "properties": {
115                                "kind": { "type": "string", "enum": ["cron"] },
116                                "expr": { "type": "string", "description": "Standard 5-field cron expression, e.g. '*/5 * * * *'" },
117                                "tz": { "type": "string", "description": CRON_TZ_DESCRIPTION }
118                            },
119                            "required": ["kind", "expr"]
120                        },
121                        {
122                            "type": "object",
123                            "description": "One-shot schedule at a specific RFC3339 timestamp with explicit Z or offset. Example: {\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}",
124                            "properties": {
125                                "kind": { "type": "string", "enum": ["at"] },
126                                "at": { "type": "string", "description": AT_DESCRIPTION }
127                            },
128                            "required": ["kind", "at"]
129                        },
130                        {
131                            "type": "object",
132                            "description": "Repeating interval schedule in milliseconds. Example: {\"kind\":\"every\",\"every_ms\":3600000} runs every hour.",
133                            "properties": {
134                                "kind": { "type": "string", "enum": ["every"] },
135                                "every_ms": { "type": "integer", "description": "Interval in milliseconds, e.g. 3600000 for every hour" }
136                            },
137                            "required": ["kind", "every_ms"]
138                        }
139                    ]
140                },
141                "job_type": {
142                    "type": "string",
143                    "enum": ["shell", "agent"],
144                    "description": "Type of job: 'shell' runs a command, 'agent' runs the AI agent with a prompt"
145                },
146                "command": {
147                    "type": "string",
148                    "description": "Shell command to run (required when job_type is 'shell')"
149                },
150                "prompt": {
151                    "type": "string",
152                    "description": "Agent prompt to run on schedule (required when job_type is 'agent')"
153                },
154                "session_target": {
155                    "type": "string",
156                    "enum": ["isolated", "main"],
157                    "description": "Agent session context: 'isolated' starts a fresh session each run, 'main' reuses the primary session"
158                },
159                "model": {
160                    "type": "string",
161                    "description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
162                },
163                "allowed_tools": {
164                    "type": "array",
165                    "items": { "type": "string" },
166                    "description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available."
167                },
168                "delivery": {
169                    "type": "object",
170                    "description": "Optional delivery config to send job output to a channel after each run. When provided, all three of mode, channel, and to are expected.",
171                    "properties": {
172                        "mode": {
173                            "type": "string",
174                            "enum": ["none", "announce"],
175                            "description": "'announce' sends output to the specified channel; 'none' disables delivery"
176                        },
177                        "channel": {
178                            "type": "string",
179                            "enum": ["telegram", "discord", "slack", "mattermost", "matrix", "qq", "webhook", "lark", "feishu"],
180                            "description": "Channel type to deliver output to"
181                        },
182                        "to": {
183                            "type": "string",
184                            "description": "Destination ID: Discord channel ID, Telegram chat ID, Slack channel name, webhook recipient, etc."
185                        },
186                        "thread_id": {
187                            "type": "string",
188                            "description": "Optional thread/conversation identifier. Used by the webhook channel to route callbacks to the originating conversation; ignored by channels whose threading is implied by `to`."
189                        },
190                        "best_effort": {
191                            "type": "boolean",
192                            "description": "If true, a delivery failure does not fail the job itself. Defaults to true."
193                        }
194                    }
195                },
196                "delete_after_run": {
197                    "type": "boolean",
198                    "description": "If true, the job is automatically deleted after its first successful run. Defaults to true for 'at' schedules."
199                },
200                "approved": {
201                    "type": "boolean",
202                    "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
203                    "default": false
204                }
205            },
206            "required": ["schedule"]
207        })
208    }
209
210    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
211        if !self.config.scheduler.enabled {
212            return Ok(ToolResult {
213                success: false,
214                output: String::new(),
215                error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
216            });
217        }
218
219        let schedule = match args.get("schedule") {
220            Some(v @ serde_json::Value::String(raw)) => {
221                if let Some(error) = Self::plain_string_schedule_error(raw) {
222                    return Ok(ToolResult {
223                        success: false,
224                        output: String::new(),
225                        error: Some(error),
226                    });
227                }
228
229                match deserialize_schedule_arg(v) {
230                    Ok(schedule) => schedule,
231                    Err(error) => {
232                        return Ok(ToolResult {
233                            success: false,
234                            output: String::new(),
235                            error: Some(error),
236                        });
237                    }
238                }
239            }
240            Some(v) => match deserialize_schedule_arg(v) {
241                Ok(schedule) => schedule,
242                Err(error) => {
243                    return Ok(ToolResult {
244                        success: false,
245                        output: String::new(),
246                        error: Some(error),
247                    });
248                }
249            },
250            None => {
251                return Ok(ToolResult {
252                    success: false,
253                    output: String::new(),
254                    error: Some("Missing 'schedule' parameter".to_string()),
255                });
256            }
257        };
258
259        let name = args
260            .get("name")
261            .and_then(serde_json::Value::as_str)
262            .map(str::to_string);
263
264        let job_type = match args.get("job_type").and_then(serde_json::Value::as_str) {
265            Some("agent") => JobType::Agent,
266            Some("shell") => JobType::Shell,
267            Some(other) => {
268                return Ok(ToolResult {
269                    success: false,
270                    output: String::new(),
271                    error: Some(format!("Invalid job_type: {other}")),
272                });
273            }
274            None => {
275                if args.get("prompt").is_some() {
276                    JobType::Agent
277                } else {
278                    JobType::Shell
279                }
280            }
281        };
282
283        let default_delete_after_run = matches!(schedule, Schedule::At { .. });
284        let delete_after_run = args
285            .get("delete_after_run")
286            .and_then(serde_json::Value::as_bool)
287            .unwrap_or(default_delete_after_run);
288        let approved = args
289            .get("approved")
290            .and_then(serde_json::Value::as_bool)
291            .unwrap_or(false);
292        let delivery = match args.get("delivery") {
293            Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
294                Ok(cfg) => Some(cfg),
295                Err(e) => {
296                    return Ok(ToolResult {
297                        success: false,
298                        output: String::new(),
299                        error: Some(format!("Invalid delivery config: {e}")),
300                    });
301                }
302            },
303            None => None,
304        };
305
306        let result = match job_type {
307            JobType::Shell => {
308                let command = match args.get("command").and_then(serde_json::Value::as_str) {
309                    Some(command) if !command.trim().is_empty() => command,
310                    _ => {
311                        return Ok(ToolResult {
312                            success: false,
313                            output: String::new(),
314                            error: Some("Missing 'command' for shell job".to_string()),
315                        });
316                    }
317                };
318
319                if let Err(reason) = self.security.validate_command_execution(command, approved) {
320                    return Ok(ToolResult {
321                        success: false,
322                        output: String::new(),
323                        error: Some(reason),
324                    });
325                }
326
327                if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
328                    return Ok(blocked);
329                }
330
331                cron::add_shell_job_with_approval(
332                    &self.config,
333                    &self.agent_alias,
334                    name,
335                    schedule,
336                    command,
337                    delivery,
338                    approved,
339                )
340            }
341            JobType::Agent => {
342                let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) {
343                    Some(prompt) if !prompt.trim().is_empty() => prompt,
344                    _ => {
345                        return Ok(ToolResult {
346                            success: false,
347                            output: String::new(),
348                            error: Some("Missing 'prompt' for agent job".to_string()),
349                        });
350                    }
351                };
352
353                let session_target = match args.get("session_target") {
354                    Some(v) => match serde_json::from_value::<SessionTarget>(v.clone()) {
355                        Ok(target) => target,
356                        Err(e) => {
357                            return Ok(ToolResult {
358                                success: false,
359                                output: String::new(),
360                                error: Some(format!("Invalid session_target: {e}")),
361                            });
362                        }
363                    },
364                    None => SessionTarget::Isolated,
365                };
366
367                let model = args
368                    .get("model")
369                    .and_then(serde_json::Value::as_str)
370                    .map(str::to_string);
371                let allowed_tools = match args.get("allowed_tools") {
372                    Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
373                        Ok(v) => {
374                            if v.is_empty() {
375                                None // Treat empty list same as unset
376                            } else {
377                                Some(v)
378                            }
379                        }
380                        Err(e) => {
381                            return Ok(ToolResult {
382                                success: false,
383                                output: String::new(),
384                                error: Some(format!("Invalid allowed_tools: {e}")),
385                            });
386                        }
387                    },
388                    None => None,
389                };
390
391                if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
392                    return Ok(blocked);
393                }
394
395                cron::add_agent_job(
396                    &self.config,
397                    &self.agent_alias,
398                    name,
399                    schedule,
400                    prompt,
401                    session_target,
402                    model,
403                    delivery,
404                    delete_after_run,
405                    allowed_tools,
406                )
407            }
408        };
409
410        match result {
411            Ok(job) => Ok(ToolResult {
412                success: true,
413                output: serde_json::to_string_pretty(&cron_add_output(&job))?,
414                error: None,
415            }),
416            Err(e) => Ok(ToolResult {
417                success: false,
418                output: String::new(),
419                error: Some(e.to_string()),
420            }),
421        }
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::security::AutonomyLevel;
429    use tempfile::TempDir;
430    use zeroclaw_config::schema::Config;
431
432    const TEST_AGENT: &str = "test-agent";
433
434    async fn test_config(tmp: &TempDir) -> Arc<Config> {
435        let mut config = Config {
436            data_dir: tmp.path().join("data"),
437            config_path: tmp.path().join("config.toml"),
438            ..Config::default()
439        };
440        seed_test_agent(&mut config);
441        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
442        Arc::new(config)
443    }
444
445    fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
446        Arc::new(
447            SecurityPolicy::for_agent(cfg, TEST_AGENT).expect("test-agent has resolvable profiles"),
448        )
449    }
450
451    fn seed_test_agent(config: &mut Config) {
452        config
453            .risk_profiles
454            .entry(TEST_AGENT.to_string())
455            .or_default();
456        config
457            .runtime_profiles
458            .entry(TEST_AGENT.to_string())
459            .or_default();
460        config
461            .providers
462            .models
463            .ensure("openrouter", TEST_AGENT)
464            .expect("known family");
465        config.agents.entry(TEST_AGENT.to_string()).or_insert(
466            zeroclaw_config::schema::AliasedAgentConfig {
467                model_provider: format!("openrouter.{TEST_AGENT}").into(),
468                risk_profile: TEST_AGENT.to_string(),
469                runtime_profile: TEST_AGENT.to_string(),
470                ..Default::default()
471            },
472        );
473    }
474
475    #[tokio::test]
476    async fn adds_shell_job() {
477        let tmp = TempDir::new().unwrap();
478        let cfg = test_config(&tmp).await;
479        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
480        let result = tool
481            .execute(json!({
482                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
483                "job_type": "shell",
484                "command": "echo ok"
485            }))
486            .await
487            .unwrap();
488
489        assert!(result.success, "{:?}", result.error);
490        assert!(result.output.contains("next_run"));
491    }
492
493    #[tokio::test]
494    async fn output_includes_timezone_confirmation_fields_for_explicit_cron_timezone() {
495        let tmp = TempDir::new().unwrap();
496        let cfg = test_config(&tmp).await;
497        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
498        let result = tool
499            .execute(json!({
500                "schedule": { "kind": "cron", "expr": "0 9 * * 1-5", "tz": "America/New_York" },
501                "job_type": "shell",
502                "command": "echo ok"
503            }))
504            .await
505            .unwrap();
506
507        assert!(result.success, "{:?}", result.error);
508        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
509        assert_eq!(output["next_run"], output["next_run_utc"]);
510        assert_eq!(output["schedule_timezone"], "America/New_York");
511        assert_eq!(output["timezone_source"], "explicit");
512        assert!(
513            output["next_run_local"]
514                .as_str()
515                .is_some_and(|value| value.contains("T09:00:00")),
516            "next_run_local should display the next run in the explicit schedule timezone: {output}"
517        );
518    }
519
520    #[tokio::test]
521    async fn output_identifies_runtime_local_fallback_when_cron_timezone_is_omitted() {
522        let tmp = TempDir::new().unwrap();
523        let cfg = test_config(&tmp).await;
524        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
525        let result = tool
526            .execute(json!({
527                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
528                "job_type": "shell",
529                "command": "echo ok"
530            }))
531            .await
532            .unwrap();
533
534        assert!(result.success, "{:?}", result.error);
535        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
536        assert_eq!(output["timezone_source"], "runtime_local");
537        assert_eq!(output["schedule_timezone"], "runtime local timezone");
538        assert!(
539            output["next_run_local"].as_str().is_some(),
540            "next_run_local should be present for runtime-local cron schedules: {output}"
541        );
542    }
543
544    #[tokio::test]
545    async fn shell_job_persists_delivery() {
546        let tmp = TempDir::new().unwrap();
547        let cfg = test_config(&tmp).await;
548        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
549        let result = tool
550            .execute(json!({
551                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
552                "job_type": "shell",
553                "command": "echo ok",
554                "delivery": {
555                    "mode": "announce",
556                    "channel": "discord",
557                    "to": "1234567890",
558                    "best_effort": true
559                }
560            }))
561            .await
562            .unwrap();
563
564        assert!(result.success, "{:?}", result.error);
565
566        let jobs = cron::list_jobs(&cfg).unwrap();
567        assert_eq!(jobs.len(), 1);
568        assert_eq!(jobs[0].delivery.mode, "announce");
569        assert_eq!(jobs[0].delivery.channel.as_deref(), Some("discord"));
570        assert_eq!(jobs[0].delivery.to.as_deref(), Some("1234567890"));
571        assert!(jobs[0].delivery.best_effort);
572    }
573
574    #[tokio::test]
575    async fn blocks_disallowed_shell_command() {
576        let tmp = TempDir::new().unwrap();
577        let mut config = Config {
578            data_dir: tmp.path().join("data"),
579            config_path: tmp.path().join("config.toml"),
580            ..Config::default()
581        };
582        seed_test_agent(&mut config);
583        config
584            .risk_profiles
585            .entry(TEST_AGENT.into())
586            .or_default()
587            .allowed_commands = vec!["echo".into()];
588        config
589            .risk_profiles
590            .entry(TEST_AGENT.into())
591            .or_default()
592            .level = AutonomyLevel::Supervised;
593        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
594        let cfg = Arc::new(config);
595        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
596
597        let result = tool
598            .execute(json!({
599                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
600                "job_type": "shell",
601                "command": "curl https://example.com"
602            }))
603            .await
604            .unwrap();
605
606        assert!(!result.success);
607        assert!(result.error.unwrap_or_default().contains("not allowed"));
608    }
609
610    #[tokio::test]
611    async fn blocks_mutation_in_read_only_mode() {
612        let tmp = TempDir::new().unwrap();
613        let mut config = Config {
614            data_dir: tmp.path().join("data"),
615            config_path: tmp.path().join("config.toml"),
616            ..Config::default()
617        };
618        seed_test_agent(&mut config);
619        config
620            .risk_profiles
621            .entry(TEST_AGENT.into())
622            .or_default()
623            .level = AutonomyLevel::ReadOnly;
624        std::fs::create_dir_all(&config.data_dir).unwrap();
625        let cfg = Arc::new(config);
626        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
627
628        let result = tool
629            .execute(json!({
630                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
631                "job_type": "shell",
632                "command": "echo ok"
633            }))
634            .await
635            .unwrap();
636
637        assert!(!result.success);
638        let error = result.error.unwrap_or_default();
639        assert!(error.contains("read-only") || error.contains("not allowed"));
640    }
641
642    #[tokio::test]
643    async fn blocks_add_when_rate_limited() {
644        let tmp = TempDir::new().unwrap();
645        let mut config = Config {
646            data_dir: tmp.path().join("data"),
647            config_path: tmp.path().join("config.toml"),
648            ..Config::default()
649        };
650        seed_test_agent(&mut config);
651        config
652            .risk_profiles
653            .entry(TEST_AGENT.into())
654            .or_default()
655            .level = AutonomyLevel::Full;
656        config
657            .runtime_profiles
658            .entry(TEST_AGENT.into())
659            .or_default()
660            .max_actions_per_hour = 0;
661        std::fs::create_dir_all(&config.data_dir).unwrap();
662        let cfg = Arc::new(config);
663        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
664
665        let result = tool
666            .execute(json!({
667                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
668                "job_type": "shell",
669                "command": "echo ok"
670            }))
671            .await
672            .unwrap();
673
674        assert!(!result.success);
675        assert!(
676            result
677                .error
678                .unwrap_or_default()
679                .contains("Rate limit exceeded")
680        );
681        assert!(cron::list_jobs(&cfg).unwrap().is_empty());
682    }
683
684    #[tokio::test]
685    async fn medium_risk_shell_command_requires_approval() {
686        let tmp = TempDir::new().unwrap();
687        let mut config = Config {
688            data_dir: tmp.path().join("data"),
689            config_path: tmp.path().join("config.toml"),
690            ..Config::default()
691        };
692        seed_test_agent(&mut config);
693        config
694            .risk_profiles
695            .entry(TEST_AGENT.into())
696            .or_default()
697            .allowed_commands = vec!["touch".into()];
698        config
699            .risk_profiles
700            .entry(TEST_AGENT.into())
701            .or_default()
702            .level = AutonomyLevel::Supervised;
703        std::fs::create_dir_all(&config.data_dir).unwrap();
704        let cfg = Arc::new(config);
705        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
706
707        let denied = tool
708            .execute(json!({
709                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
710                "job_type": "shell",
711                "command": "touch cron-approval-test"
712            }))
713            .await
714            .unwrap();
715        assert!(!denied.success);
716        assert!(
717            denied
718                .error
719                .unwrap_or_default()
720                .contains("explicit approval")
721        );
722
723        let approved = tool
724            .execute(json!({
725                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
726                "job_type": "shell",
727                "command": "touch cron-approval-test",
728                "approved": true
729            }))
730            .await
731            .unwrap();
732        assert!(approved.success, "{:?}", approved.error);
733    }
734
735    #[tokio::test]
736    async fn accepts_schedule_passed_as_json_string() {
737        let tmp = TempDir::new().unwrap();
738        let cfg = test_config(&tmp).await;
739        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
740
741        // Simulate the LLM double-serializing the schedule: the value arrives
742        // as a JSON string containing a JSON object, rather than an object.
743        let result = tool
744            .execute(json!({
745                "schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
746                "job_type": "shell",
747                "command": "echo string-schedule"
748            }))
749            .await
750            .unwrap();
751
752        assert!(result.success, "{:?}", result.error);
753        assert!(result.output.contains("next_run"));
754    }
755
756    #[tokio::test]
757    async fn rejects_plain_string_schedule_with_actionable_error() {
758        let tmp = TempDir::new().unwrap();
759        let cfg = test_config(&tmp).await;
760        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
761
762        let result = tool
763            .execute(json!({
764                "schedule": "0 9 * * 1-5",
765                "job_type": "shell",
766                "command": "echo bad-schedule"
767            }))
768            .await
769            .unwrap();
770
771        assert!(!result.success);
772        let error = result.error.unwrap_or_default();
773        assert!(error.contains("expected a JSON object"));
774        assert!(error.contains("\"kind\""));
775        assert!(error.contains("plain string \"0 9 * * 1-5\""));
776        assert!(error.contains("{\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\"}"));
777        assert!(error.contains("{\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}"));
778        assert!(error.contains("{\"kind\":\"every\",\"every_ms\":3600000}"));
779        assert!(!error.contains("internally tagged enum"));
780    }
781
782    #[tokio::test]
783    async fn accepts_stringified_interval_schedule() {
784        let tmp = TempDir::new().unwrap();
785        let cfg = test_config(&tmp).await;
786        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
787
788        let result = tool
789            .execute(json!({
790                "schedule": r#"{"kind":"every","every_ms":60000}"#,
791                "job_type": "shell",
792                "command": "echo interval"
793            }))
794            .await
795            .unwrap();
796
797        assert!(result.success, "{:?}", result.error);
798    }
799
800    #[tokio::test]
801    async fn accepts_stringified_schedule_with_timezone() {
802        let tmp = TempDir::new().unwrap();
803        let cfg = test_config(&tmp).await;
804        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
805
806        let result = tool
807            .execute(json!({
808                "schedule": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
809                "job_type": "shell",
810                "command": "echo tz-test"
811            }))
812            .await
813            .unwrap();
814
815        assert!(result.success, "{:?}", result.error);
816    }
817
818    #[tokio::test]
819    async fn rejects_invalid_schedule() {
820        let tmp = TempDir::new().unwrap();
821        let cfg = test_config(&tmp).await;
822        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
823
824        let result = tool
825            .execute(json!({
826                "schedule": { "kind": "every", "every_ms": 0 },
827                "job_type": "shell",
828                "command": "echo nope"
829            }))
830            .await
831            .unwrap();
832
833        assert!(!result.success);
834        assert!(
835            result
836                .error
837                .unwrap_or_default()
838                .contains("every_ms must be > 0")
839        );
840    }
841
842    #[tokio::test]
843    async fn rejects_at_timestamp_without_explicit_offset_with_actionable_error() {
844        let tmp = TempDir::new().unwrap();
845        let cfg = test_config(&tmp).await;
846        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
847
848        let result = tool
849            .execute(json!({
850                "schedule": { "kind": "at", "at": "2026-05-18T09:00:00" },
851                "job_type": "shell",
852                "command": "echo at"
853            }))
854            .await
855            .unwrap();
856
857        assert!(!result.success);
858        let error = result.error.unwrap_or_default();
859        assert!(
860            error.contains("RFC3339 timestamp with explicit Z or offset"),
861            "error should explain the explicit offset requirement: {error}"
862        );
863        assert!(error.contains("2026-05-18T09:00:00Z"));
864        assert!(error.contains("2026-05-18T09:00:00-04:00"));
865    }
866
867    #[tokio::test]
868    async fn agent_job_requires_prompt() {
869        let tmp = TempDir::new().unwrap();
870        let cfg = test_config(&tmp).await;
871        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
872
873        let result = tool
874            .execute(json!({
875                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
876                "job_type": "agent"
877            }))
878            .await
879            .unwrap();
880        assert!(!result.success);
881        assert!(
882            result
883                .error
884                .unwrap_or_default()
885                .contains("Missing 'prompt'")
886        );
887    }
888
889    #[tokio::test]
890    async fn agent_job_persists_allowed_tools() {
891        let tmp = TempDir::new().unwrap();
892        let cfg = test_config(&tmp).await;
893        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
894
895        let result = tool
896            .execute(json!({
897                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
898                "job_type": "agent",
899                "prompt": "check status",
900                "allowed_tools": ["file_read", "web_search"]
901            }))
902            .await
903            .unwrap();
904
905        assert!(result.success, "{:?}", result.error);
906
907        let jobs = cron::list_jobs(&cfg).unwrap();
908        assert_eq!(jobs.len(), 1);
909        assert_eq!(
910            jobs[0].allowed_tools,
911            Some(vec!["file_read".into(), "web_search".into()])
912        );
913    }
914
915    #[tokio::test]
916    async fn empty_allowed_tools_stored_as_none() {
917        let tmp = TempDir::new().unwrap();
918        let cfg = test_config(&tmp).await;
919        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
920
921        let result = tool
922            .execute(json!({
923                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
924                "job_type": "agent",
925                "prompt": "check status",
926                "allowed_tools": []
927            }))
928            .await
929            .unwrap();
930
931        assert!(result.success, "{:?}", result.error);
932
933        let jobs = cron::list_jobs(&cfg).unwrap();
934        assert_eq!(jobs.len(), 1);
935        assert_eq!(
936            jobs[0].allowed_tools, None,
937            "empty allowed_tools should be stored as None"
938        );
939    }
940
941    #[tokio::test]
942    async fn delivery_schema_includes_matrix_channel() {
943        let tmp = TempDir::new().unwrap();
944        let cfg = test_config(&tmp).await;
945        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
946
947        let values =
948            tool.parameters_schema()["properties"]["delivery"]["properties"]["channel"]["enum"]
949                .as_array()
950                .cloned()
951                .unwrap_or_default();
952
953        assert!(values.iter().any(|value| value == "matrix"));
954    }
955
956    #[tokio::test]
957    async fn delivery_schema_includes_webhook_and_thread_id() {
958        let tmp = TempDir::new().unwrap();
959        let cfg = test_config(&tmp).await;
960        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
961        let schema = tool.parameters_schema();
962
963        let channel_enum = schema["properties"]["delivery"]["properties"]["channel"]["enum"]
964            .as_array()
965            .cloned()
966            .unwrap_or_default();
967        assert!(
968            channel_enum.iter().any(|value| value == "webhook"),
969            "delivery.channel enum must include webhook"
970        );
971
972        let delivery_props = schema["properties"]["delivery"]["properties"]
973            .as_object()
974            .expect("delivery must have properties");
975        assert!(
976            delivery_props.contains_key("thread_id"),
977            "delivery schema must expose thread_id so the webhook channel can route callbacks"
978        );
979    }
980
981    #[tokio::test]
982    async fn webhook_announce_job_persists_thread_id() {
983        let tmp = TempDir::new().unwrap();
984        let cfg = test_config(&tmp).await;
985        let tool = CronAddTool::new(cfg.clone(), test_security(&cfg), TEST_AGENT);
986        let result = tool
987            .execute(json!({
988                "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
989                "job_type": "shell",
990                "command": "echo ok",
991                "delivery": {
992                    "mode": "announce",
993                    "channel": "webhook",
994                    "to": "user-42",
995                    "thread_id": "conv-99",
996                    "best_effort": true
997                }
998            }))
999            .await
1000            .unwrap();
1001
1002        assert!(result.success, "{:?}", result.error);
1003
1004        let jobs = cron::list_jobs(&cfg).unwrap();
1005        assert_eq!(jobs.len(), 1);
1006        assert_eq!(jobs[0].delivery.mode, "announce");
1007        assert_eq!(jobs[0].delivery.channel.as_deref(), Some("webhook"));
1008        assert_eq!(jobs[0].delivery.to.as_deref(), Some("user-42"));
1009        assert_eq!(jobs[0].delivery.thread_id.as_deref(), Some("conv-99"));
1010        assert!(jobs[0].delivery.best_effort);
1011    }
1012
1013    #[test]
1014    fn schedule_schema_is_oneof_with_cron_at_every_variants() {
1015        let tmp = tempfile::TempDir::new().unwrap();
1016        let cfg = Arc::new(Config {
1017            data_dir: tmp.path().join("data"),
1018            config_path: tmp.path().join("config.toml"),
1019            ..Config::default()
1020        });
1021        let security = Arc::new(SecurityPolicy::from_risk_profile(
1022            &zeroclaw_config::schema::RiskProfileConfig::default(),
1023            &cfg.data_dir,
1024        ));
1025        let tool = CronAddTool::new(cfg, security, TEST_AGENT);
1026        let schema = tool.parameters_schema();
1027
1028        // Top-level: schedule is required
1029        let top_required = schema["required"].as_array().expect("top-level required");
1030        assert!(top_required.iter().any(|v| v == "schedule"));
1031
1032        // schedule is a oneOf with exactly 3 variants: cron, at, every
1033        let one_of = schema["properties"]["schedule"]["oneOf"]
1034            .as_array()
1035            .expect("schedule.oneOf must be an array");
1036        assert_eq!(one_of.len(), 3, "expected cron, at, and every variants");
1037
1038        let kinds: Vec<&str> = one_of
1039            .iter()
1040            .filter_map(|v| v["properties"]["kind"]["enum"][0].as_str())
1041            .collect();
1042        assert!(kinds.contains(&"cron"), "missing cron variant");
1043        assert!(kinds.contains(&"at"), "missing at variant");
1044        assert!(kinds.contains(&"every"), "missing every variant");
1045
1046        // Each variant declares its required fields and every_ms is typed integer
1047        for variant in one_of {
1048            let kind = variant["properties"]["kind"]["enum"][0]
1049                .as_str()
1050                .expect("variant kind");
1051            let req: Vec<&str> = variant["required"]
1052                .as_array()
1053                .unwrap_or_else(|| panic!("{kind} variant must have required"))
1054                .iter()
1055                .filter_map(|v| v.as_str())
1056                .collect();
1057            assert!(
1058                req.contains(&"kind"),
1059                "{kind} variant missing 'kind' in required"
1060            );
1061            match kind {
1062                "cron" => assert!(req.contains(&"expr"), "cron variant missing 'expr'"),
1063                "at" => assert!(req.contains(&"at"), "at variant missing 'at'"),
1064                "every" => {
1065                    assert!(
1066                        req.contains(&"every_ms"),
1067                        "every variant missing 'every_ms'"
1068                    );
1069                    assert_eq!(
1070                        variant["properties"]["every_ms"]["type"].as_str(),
1071                        Some("integer"),
1072                        "every_ms must be typed as integer"
1073                    );
1074                }
1075                _ => panic!("unexpected kind: {kind}"),
1076            }
1077        }
1078
1079        let cron_variant = one_of
1080            .iter()
1081            .find(|variant| variant["properties"]["kind"]["enum"][0] == "cron")
1082            .expect("cron variant");
1083        let cron_tz_description = cron_variant["properties"]["tz"]["description"]
1084            .as_str()
1085            .expect("cron tz description");
1086        assert!(
1087            cron_tz_description.contains("runtime local timezone"),
1088            "cron tz description must match scheduler fallback: {cron_tz_description}"
1089        );
1090        assert!(
1091            cron_tz_description.contains("explicit IANA timezone"),
1092            "cron tz description should recommend explicit IANA timezones: {cron_tz_description}"
1093        );
1094        assert!(
1095            !cron_tz_description.contains("Defaults to UTC"),
1096            "cron tz description must not claim a UTC default"
1097        );
1098
1099        let at_variant = one_of
1100            .iter()
1101            .find(|variant| variant["properties"]["kind"]["enum"][0] == "at")
1102            .expect("at variant");
1103        let at_description = at_variant["properties"]["at"]["description"]
1104            .as_str()
1105            .expect("at description");
1106        assert!(
1107            at_description.contains("RFC3339 timestamp with explicit Z or offset"),
1108            "at description should require explicit Z or offset: {at_description}"
1109        );
1110    }
1111}