1use std::sync::Arc;
22use std::time::SystemTime;
23
24use forge_sdk::mcp::server::McpServerBuilder;
25use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
26
27use forge_primitives::cron::{CronEntry, CronId, CronKind};
28
29use crate::SessionSlot;
30use crate::mcp::cron::facade::{CronCreateError, CronDeleteError, CronFacade, CronListError};
31
32pub(crate) mod facade;
33pub(crate) mod schedule;
34
35pub(crate) fn add_tools(
39 builder: McpServerBuilder,
40 facade: Arc<dyn CronFacade>,
41 slot: SessionSlot,
42) -> McpServerBuilder {
43 let create = Create { facade: facade.clone(), slot: slot.clone() };
44 let list = List { facade: facade.clone(), slot: slot.clone() };
45 let delete = Delete { facade, slot };
46 builder.tool(create).tool(list).tool(delete)
47}
48
49fn tool_error(text: String) -> ToolOutput {
50 ToolOutput::error(text)
51}
52
53fn parse_rfc3339(s: &str) -> Option<SystemTime> {
56 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
57 .ok()
58 .map(SystemTime::from)
59}
60
61fn fmt_rfc3339(t: SystemTime) -> String {
63 time::OffsetDateTime::from(t)
64 .format(&time::format_description::well_known::Rfc3339)
65 .unwrap_or_else(|_| "unknown".to_owned())
66}
67
68fn cron_to_json(entry: &CronEntry) -> serde_json::Value {
70 let schedule = match &entry.kind {
71 CronKind::Recurring(expr) => serde_json::json!({ "recurring": expr }),
72 CronKind::Once(at) => serde_json::json!({ "once_at": fmt_rfc3339(*at) }),
73 };
74 let mut obj = serde_json::json!({
75 "id": entry.id.as_str(),
76 "project": entry.project_name,
77 "schedule": schedule,
78 "prompt": entry.prompt,
79 "next_fire": fmt_rfc3339(entry.next_fire),
80 });
81 if let (Some(desc), Some(map)) = (&entry.description, obj.as_object_mut()) {
82 map.insert("description".to_owned(), serde_json::Value::String(desc.clone()));
83 }
84 if !entry.wake
85 && let Some(map) = obj.as_object_mut()
86 {
87 map.insert("wake".to_owned(), serde_json::Value::Bool(false));
88 }
89 obj
90}
91
92fn format_create_error(err: &CronCreateError) -> String {
93 match err {
94 CronCreateError::UnknownCallerProject => {
95 "couldn't resolve your project; is this session attached to a forge.toml project?"
96 .to_owned()
97 }
98 CronCreateError::InvalidExpression(msg) => format!("invalid cron expression: {msg}"),
99 CronCreateError::NoUpcomingOccurrence => {
100 "that schedule has no upcoming occurrence (a run-once time in the past, or a cron \
101 expression that never matches)"
102 .to_owned()
103 }
104 }
105}
106
107struct Create {
108 facade: Arc<dyn CronFacade>,
109 slot: SessionSlot,
110}
111
112#[derive(serde::Deserialize)]
113struct CreateArgs {
114 #[serde(default)]
115 schedule: Option<String>,
116 #[serde(default)]
117 run_once_at: Option<String>,
118 prompt: String,
119 #[serde(default)]
120 description: Option<String>,
121 #[serde(default)]
122 wake: Option<bool>,
123}
124
125#[async_trait::async_trait]
126impl Tool for Create {
127 fn name(&self) -> &'static str {
128 "cron__create"
129 }
130
131 fn description(&self) -> &'static str {
132 "Schedule a durable prompt that fires into the session that registers it and survives \
133 forge restarts. Provide EXACTLY ONE of `schedule` (a 5-field cron expression like \
134 \"0 9 * * *\" for 9am daily, evaluated in your local timezone) or `run_once_at` (an \
135 RFC3339 timestamp like \"2026-07-01T09:00:00Z\" for a single fire), plus `prompt` (the \
136 text delivered as a user turn when it fires). Optionally pass `description` - a short \
137 human summary of what the job does and why - which the UI shows as the schedule's \
138 headline. If that session isn't open at fire time, forge spawns it first, unless it can \
139 no longer be started (a worker whose directory is gone); pass `wake: false` for a \
140 job that must not wake a sleeping owner, where a tick that finds the \
141 session asleep is skipped and the entry stays due for the next session start. \
142 Returns the cron's id (use it \
143 with cron__delete). Recurring crons repeat; run-once crons delete themselves after \
144 firing. A run-once cron is a poor fit for retrying a transient failure - a rate limit \
145 or a blip clears in seconds, so the retry fires later as a user turn into a session \
146 that has moved on. The cron is registered to you, and cron__list and cron__delete act \
147 on the crons you registered. Any session in the project may create crons."
148 }
149
150 fn input_schema(&self) -> serde_json::Value {
151 serde_json::json!({
152 "type": "object",
153 "properties": {
154 "schedule": {
155 "type": "string",
156 "description": "A 5-field cron expression (minute hour day month weekday), \
157 evaluated in the host's local timezone. Mutually exclusive \
158 with run_once_at.",
159 },
160 "run_once_at": {
161 "type": "string",
162 "description": "An RFC3339 timestamp for a single fire. Mutually exclusive \
163 with schedule.",
164 },
165 "prompt": {
166 "type": "string",
167 "description": "The prompt delivered as a user turn when the cron fires.",
168 },
169 "description": {
170 "type": "string",
171 "description": "Optional short human summary of what this job does and why \
172 (e.g. \"Morning summary\"). Shown as the schedule's \
173 headline in the UI; falls back to the prompt's first line \
174 when omitted.",
175 },
176 "wake": {
177 "type": "boolean",
178 "description": "Whether a fire may wake a sleeping session (default true). \
179 Set false for a job that must not wake a sleeping \
180 owner: a tick that finds the session asleep is skipped and \
181 the entry stays due, so the next session start (or a tick \
182 while it is awake) runs it.",
183 },
184 },
185 "required": ["prompt"],
186 "additionalProperties": false,
187 })
188 }
189
190 async fn call(&self, input: ToolInput) -> ToolOutput {
191 let args: CreateArgs = match serde_json::from_value(input.value) {
192 Ok(a) => a,
193 Err(err) => return tool_error(format!("invalid arguments: {err}")),
194 };
195 let kind = match (args.schedule, args.run_once_at) {
196 (Some(expr), None) => CronKind::Recurring(expr),
197 (None, Some(rfc)) => match parse_rfc3339(&rfc) {
198 Some(t) => CronKind::Once(t),
199 None => {
200 return tool_error(format!(
201 "run_once_at must be an RFC3339 timestamp (e.g. 2026-07-01T09:00:00Z); \
202 got {rfc:?}"
203 ));
204 }
205 },
206 (Some(_), Some(_)) => {
207 return tool_error(
208 "provide exactly one of `schedule` or `run_once_at`, not both".to_owned(),
209 );
210 }
211 (None, None) => {
212 return tool_error(
213 "provide either `schedule` (a 5-field cron expression) or `run_once_at` (an \
214 RFC3339 timestamp)"
215 .to_owned(),
216 );
217 }
218 };
219 let description = args.description.and_then(|d| {
220 let trimmed = d.trim();
221 (!trimmed.is_empty()).then(|| trimmed.to_owned())
222 });
223 match self.facade.create_cron(
224 &self.slot,
225 kind,
226 args.prompt,
227 description,
228 args.wake.unwrap_or(true),
229 ) {
230 Ok(entry) => match serde_json::to_string_pretty(&cron_to_json(&entry)) {
231 Ok(json) => ToolOutput::text(json),
232 Err(err) => tool_error(format!("response serialization failed: {err}")),
233 },
234 Err(err) => tool_error(format_create_error(&err)),
235 }
236 }
237}
238
239struct List {
240 facade: Arc<dyn CronFacade>,
241 slot: SessionSlot,
242}
243
244#[async_trait::async_trait]
245impl Tool for List {
246 fn name(&self) -> &'static str {
247 "cron__list"
248 }
249
250 fn description(&self) -> &'static str {
251 "List the durable crons you registered, in your project. Returns a JSON array of {id, \
252 project, schedule, prompt, next_fire}. Use an id with cron__delete. An empty array means \
253 you have no crons registered; an unresolvable caller, or a run that could not read the \
254 durable cron store, is an error rather than an empty list. Takes no \
255 arguments. Any session in the project may call this."
256 }
257
258 fn input_schema(&self) -> serde_json::Value {
259 serde_json::json!({
260 "type": "object",
261 "properties": {},
262 "additionalProperties": false,
263 })
264 }
265
266 async fn call(&self, _input: ToolInput) -> ToolOutput {
267 let crons = match self.facade.list_crons(&self.slot) {
268 Ok(crons) => crons,
269 Err(CronListError::UnknownCallerProject) => {
270 return tool_error("couldn't resolve your project".to_owned());
271 }
272 Err(CronListError::Unavailable) => {
273 return tool_error(
274 "couldn't load durable crons this run (the forge log names why), so this is \
275 not an empty cron list; crons may exist that this run cannot see. Ask the \
276 user to restart forge to retry the load."
277 .to_owned(),
278 );
279 }
280 };
281 let arr: Vec<serde_json::Value> = crons.iter().map(cron_to_json).collect();
282 match serde_json::to_string_pretty(&serde_json::Value::Array(arr)) {
283 Ok(json) => ToolOutput::text(json),
284 Err(err) => tool_error(format!("cron-list serialization failed: {err}")),
285 }
286 }
287}
288
289struct Delete {
290 facade: Arc<dyn CronFacade>,
291 slot: SessionSlot,
292}
293
294#[derive(serde::Deserialize)]
295struct DeleteArgs {
296 id: String,
297}
298
299#[async_trait::async_trait]
300impl Tool for Delete {
301 fn name(&self) -> &'static str {
302 "cron__delete"
303 }
304
305 fn description(&self) -> &'static str {
306 "Delete a durable cron you registered, in your project, by id (from cron__list / \
307 cron__create). Returns the removed entry. Any session in the project may call this."
308 }
309
310 fn input_schema(&self) -> serde_json::Value {
311 serde_json::json!({
312 "type": "object",
313 "properties": {
314 "id": { "type": "string", "description": "The cron id to delete." },
315 },
316 "required": ["id"],
317 "additionalProperties": false,
318 })
319 }
320
321 async fn call(&self, input: ToolInput) -> ToolOutput {
322 let args: DeleteArgs = match serde_json::from_value(input.value) {
323 Ok(a) => a,
324 Err(err) => return tool_error(format!("invalid arguments: {err}")),
325 };
326 match self.facade.delete_cron(&self.slot, &CronId::from(args.id.as_str())) {
327 Ok(entry) => {
328 let envelope = crate::mcp::deleted::removed_record(&cron_to_json(&entry));
329 match serde_json::to_string_pretty(&envelope) {
330 Ok(json) => ToolOutput::text(json),
331 Err(err) => tool_error(format!("response serialization failed: {err}")),
332 }
333 }
334 Err(CronDeleteError::UnknownCallerProject) => {
335 tool_error("couldn't resolve your project".to_owned())
336 }
337 Err(CronDeleteError::NoSuchCron) => {
338 tool_error(format!("no cron with id {} in this project", args.id))
339 }
340 Err(CronDeleteError::NotOwnedByCaller) => tool_error(format!(
341 "cron {} exists in this project but was not registered by you",
342 args.id
343 )),
344 }
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::mcp::cron::facade::MockCronFacade;
352 use crate::mcp::test_support::text_of;
353
354 fn caller_slot() -> SessionSlot {
355 SessionSlot::from_str_for_test("caller")
356 }
357
358 fn sample_entry(id: &str) -> CronEntry {
359 CronEntry {
360 id: CronId::from(id),
361 project_name: "forge".to_owned(),
362 kind: CronKind::Recurring("0 9 * * *".to_owned()),
363 prompt: "p".to_owned(),
364 created_at: SystemTime::UNIX_EPOCH,
365 description: None,
366 last_fire: None,
367 next_fire: SystemTime::UNIX_EPOCH,
368 team_role: None,
369 wake: true,
370 }
371 }
372
373 fn input(value: serde_json::Value) -> ToolInput {
374 ToolInput { value }
375 }
376
377 #[tokio::test]
378 async fn create_with_cron_expr_calls_facade_and_returns_entry() {
379 let mock = Arc::new(MockCronFacade::new());
380 *mock.create_result.lock() = Some(Ok(sample_entry("c1")));
381 let tool = Create { facade: mock.clone(), slot: caller_slot() };
382
383 let out = tool
384 .call(input(serde_json::json!({ "schedule": "0 9 * * *", "prompt": "stand-up" })))
385 .await;
386 assert!(!out.is_error, "valid create succeeds: {}", text_of(&out));
387 assert!(text_of(&out).contains("c1"), "output carries the id");
388
389 let calls = mock.create_calls.lock();
390 assert_eq!(calls.len(), 1);
391 assert!(matches!(&calls[0].1, CronKind::Recurring(e) if e == "0 9 * * *"));
392 assert_eq!(calls[0].2, "stand-up");
393 assert_eq!(calls[0].3, None, "no description arg reaches the facade as None");
394 assert!(calls[0].4, "an unstated `wake` reaches the facade as the waking default");
395 }
396
397 #[test]
401 fn a_no_wake_entry_states_it_without_a_description() {
402 let mut entry = sample_entry("c1");
403 assert_eq!(entry.description, None, "premise: no description");
404 entry.wake = false;
405 let json = cron_to_json(&entry);
406 assert_eq!(json["wake"], serde_json::Value::Bool(false), "the no-wake flag is stated");
407 }
408
409 #[tokio::test]
413 async fn create_threads_wake_false_for_a_no_wake_job() {
414 let mock = Arc::new(MockCronFacade::new());
415 let tool = Create { facade: mock.clone(), slot: caller_slot() };
416
417 let out = tool
418 .call(input(serde_json::json!({
419 "schedule": "0 6 * * 1",
420 "prompt": "refresh the index",
421 "wake": false
422 })))
423 .await;
424 assert!(!out.is_error, "a no-wake create succeeds: {}", text_of(&out));
425 assert!(!mock.create_calls.lock()[0].4, "the no-wake flag reaches the facade");
426 }
427
428 #[tokio::test]
429 async fn create_threads_and_trims_description() {
430 let mock = Arc::new(MockCronFacade::new());
431 let tool = Create { facade: mock.clone(), slot: caller_slot() };
432
433 let out = tool
434 .call(input(serde_json::json!({
435 "schedule": "0 9 * * *",
436 "prompt": "stand-up",
437 "description": " Morning summary "
438 })))
439 .await;
440 assert!(!out.is_error, "create with description succeeds: {}", text_of(&out));
441 assert_eq!(
442 mock.create_calls.lock()[0].3.as_deref(),
443 Some("Morning summary"),
444 "the description is trimmed and threaded to the facade",
445 );
446
447 let blank = tool
448 .call(input(serde_json::json!({
449 "schedule": "0 9 * * *",
450 "prompt": "stand-up",
451 "description": " "
452 })))
453 .await;
454 assert!(!blank.is_error);
455 assert_eq!(
456 mock.create_calls.lock()[1].3,
457 None,
458 "a whitespace-only description collapses to None",
459 );
460 }
461
462 #[tokio::test]
463 async fn create_with_run_once_at_parses_rfc3339() {
464 let mock = Arc::new(MockCronFacade::new());
465 let tool = Create { facade: mock.clone(), slot: caller_slot() };
466
467 let out = tool
468 .call(input(
469 serde_json::json!({ "run_once_at": "2030-01-01T09:00:00Z", "prompt": "deploy" }),
470 ))
471 .await;
472 assert!(!out.is_error, "valid rfc3339 accepted: {}", text_of(&out));
473 assert!(matches!(mock.create_calls.lock()[0].1, CronKind::Once(_)));
474 }
475
476 #[tokio::test]
477 async fn create_rejects_bad_rfc3339_without_touching_facade() {
478 let mock = Arc::new(MockCronFacade::new());
479 let tool = Create { facade: mock.clone(), slot: caller_slot() };
480
481 let out = tool
482 .call(input(serde_json::json!({ "run_once_at": "not a date", "prompt": "x" })))
483 .await;
484 assert!(out.is_error);
485 assert!(mock.create_calls.lock().is_empty(), "invalid input never reaches the facade");
486 }
487
488 #[tokio::test]
489 async fn create_rejects_both_and_neither_schedule() {
490 let mock = Arc::new(MockCronFacade::new());
491 let tool = Create { facade: mock.clone(), slot: caller_slot() };
492
493 let both = tool
494 .call(input(serde_json::json!({
495 "schedule": "0 9 * * *",
496 "run_once_at": "2030-01-01T09:00:00Z",
497 "prompt": "x"
498 })))
499 .await;
500 assert!(both.is_error, "both schedule kinds is an error");
501
502 let neither = tool.call(input(serde_json::json!({ "prompt": "x" }))).await;
503 assert!(neither.is_error, "no schedule kind is an error");
504 }
505
506 #[tokio::test]
507 async fn create_surfaces_invalid_expression_error() {
508 let mock = Arc::new(MockCronFacade::new());
509 *mock.create_result.lock() =
510 Some(Err(CronCreateError::InvalidExpression("bad pattern".to_owned())));
511 let tool = Create { facade: mock.clone(), slot: caller_slot() };
512
513 let out =
514 tool.call(input(serde_json::json!({ "schedule": "nonsense", "prompt": "x" }))).await;
515 assert!(out.is_error);
516 assert!(text_of(&out).contains("bad pattern"), "facade error surfaced to the LLM");
517 }
518
519 #[tokio::test]
520 async fn list_returns_the_callers_crons() {
521 let mock = Arc::new(MockCronFacade::new());
522 *mock.crons.lock() = vec![sample_entry("a"), sample_entry("b")];
523 let tool = List { facade: mock.clone(), slot: caller_slot() };
524
525 let out = tool.call(input(serde_json::json!({}))).await;
526 assert!(!out.is_error);
527 assert!(text_of(&out).contains("\"a\"") && text_of(&out).contains("\"b\""));
528 }
529
530 #[tokio::test]
535 async fn list_refuses_an_unresolvable_caller_instead_of_answering_empty() {
536 let mock = Arc::new(MockCronFacade::new());
537 *mock.list_result.lock() = Some(Err(CronListError::UnknownCallerProject));
538 let tool = List { facade: mock.clone(), slot: caller_slot() };
539
540 let out = tool.call(input(serde_json::json!({}))).await;
541 assert!(
542 out.is_error,
543 "an unresolvable caller must not read as an empty registry: {}",
544 text_of(&out),
545 );
546 assert_eq!(text_of(&out), "couldn't resolve your project");
547 }
548
549 #[tokio::test]
554 async fn list_reports_an_unavailable_store_as_an_error() {
555 let mock = Arc::new(MockCronFacade::new());
556 *mock.list_result.lock() = Some(Err(CronListError::Unavailable));
557 let tool = List { facade: mock.clone(), slot: caller_slot() };
558
559 let out = tool.call(input(serde_json::json!({}))).await;
560 assert!(
561 out.is_error,
562 "an unavailable cron store must not read as an empty registry: {}",
563 text_of(&out),
564 );
565 assert!(
566 text_of(&out).contains("couldn't load durable crons"),
567 "the error names the case: {}",
568 text_of(&out),
569 );
570 assert!(
571 text_of(&out).contains("not an empty cron list"),
572 "the error says the empty set is not the registry: {}",
573 text_of(&out),
574 );
575 }
576
577 #[tokio::test]
578 async fn delete_removes_by_id() {
579 let mock = Arc::new(MockCronFacade::new());
580 *mock.delete_result.lock() = Some(Ok(sample_entry("c1")));
581 let tool = Delete { facade: mock.clone(), slot: caller_slot() };
582
583 let out = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
584 assert!(!out.is_error);
585 assert_eq!(mock.delete_calls.lock()[0].1, CronId::from("c1"));
586 }
587
588 #[tokio::test]
593 async fn delete_echoes_the_removed_entry() {
594 let mock = Arc::new(MockCronFacade::new());
595 let mut entry = sample_entry("c1");
596 entry.description = Some("Morning summary".to_owned());
597 *mock.delete_result.lock() = Some(Ok(entry.clone()));
598 let tool = Delete { facade: mock.clone(), slot: caller_slot() };
599
600 let out = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
601 assert!(!out.is_error, "delete succeeds: {out:?}");
602 assert_eq!(out.blocks.len(), 1, "the envelope stays one text block: {out:?}");
603 let json: serde_json::Value =
604 serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
605 assert_eq!(json["status"], "deleted");
606 assert_eq!(
607 json["removed"],
608 cron_to_json(&entry),
609 "the echo is the whole entry, not a hand-picked subset: {json}",
610 );
611 }
612
613 #[tokio::test]
619 async fn delete_refusals_name_the_case() {
620 let mock = Arc::new(MockCronFacade::new());
621 let tool = Delete { facade: mock.clone(), slot: caller_slot() };
622
623 *mock.delete_result.lock() = Some(Err(CronDeleteError::NotOwnedByCaller));
624 let not_yours = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
625 assert!(not_yours.is_error, "a refused delete is an error");
626 assert_eq!(
627 text_of(¬_yours),
628 "cron c1 exists in this project but was not registered by you",
629 "a cron that exists but is not the caller's own must say so, without naming a session",
630 );
631
632 *mock.delete_result.lock() = Some(Err(CronDeleteError::NoSuchCron));
633 let not_there = tool.call(input(serde_json::json!({ "id": "c1" }))).await;
634 assert!(not_there.is_error, "an unknown id is an error");
635 assert_eq!(
636 text_of(¬_there),
637 "no cron with id c1 in this project",
638 "an id the project never had must not read as another session's cron",
639 );
640 }
641
642 #[test]
647 fn tool_descriptions_scope_to_the_caller_not_the_project() {
648 const DISCLOSING: &[&str] = &[
656 "another session",
657 "other session",
658 "other worker",
659 "other owner",
660 "each session",
661 "sibling",
662 "separate sets",
663 "another agent",
664 ];
665
666 let mock = MockCronFacade::new().into_arc();
667 let slot = caller_slot();
668 let create = Create { facade: mock.clone(), slot: slot.clone() };
669 let list = List { facade: mock.clone(), slot: slot.clone() };
670 let delete = Delete { facade: mock, slot };
671 let list_desc = list.description();
672
673 for (name, desc) in [
674 ("cron__create", create.description()),
675 ("cron__list", list_desc),
676 ("cron__delete", delete.description()),
677 ] {
678 assert!(
679 desc.contains("you registered"),
680 "{name} must name the caller's own crons rather than the project: {desc}",
681 );
682 let lowered = desc.to_lowercase();
683 for shape in DISCLOSING {
684 assert!(
685 !lowered.contains(shape),
686 "{name} must not disclose that other sessions' crons exist ({shape}): {desc}",
687 );
688 }
689 }
690 assert!(
691 list_desc.contains("An empty array means you have no crons registered"),
692 "an empty list must read as the caller having none, never as the project having \
693 none: {list_desc}",
694 );
695 for stale in [
700 "could not be resolved",
701 "could not read its stored crons",
702 "could not load its durable crons",
703 ] {
704 assert!(
705 !list_desc.contains(stale),
706 "an empty list means one thing; the description must not carry the old \
707 '{stale}' caveat: {list_desc}",
708 );
709 }
710 assert!(
711 list_desc.contains("could not read the durable cron store"),
712 "the description must name the failed durable read: {list_desc}",
713 );
714 }
715
716 #[test]
720 fn cron_create_description_qualifies_the_spawn_promise() {
721 let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
722 let desc = create.description();
723 assert!(
724 desc.contains("unless it can no longer be started"),
725 "the spawn promise must carry the case where the owner cannot be started: {desc}",
726 );
727 }
728
729 #[test]
734 fn cron_create_description_keeps_the_no_wake_example_generic() {
735 let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
736 let desc = create.description();
737 assert!(
738 desc.contains("a job that must not wake a sleeping owner"),
739 "the no-wake example names the flag's meaning: {desc}",
740 );
741 assert!(
742 !desc.contains("standing sweep"),
743 "the no-wake example must not sell a standing sweep: {desc}",
744 );
745 let schema = create.input_schema();
746 let wake = schema["properties"]["wake"]["description"]
747 .as_str()
748 .expect("the wake parameter carries a description");
749 assert!(
750 wake.contains("a job that must not wake a sleeping owner"),
751 "the wake parameter names the flag's meaning: {wake}",
752 );
753 assert!(
754 !wake.contains("standing sweep"),
755 "the wake parameter must not sell a standing sweep: {wake}",
756 );
757 }
758
759 #[test]
764 fn cron_create_description_discourages_retry_crons() {
765 let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
766 let desc = create.description();
767 assert!(
768 desc.contains("A run-once cron is a poor fit for retrying a transient failure"),
769 "the retry-cron rule is pinned: {desc}",
770 );
771 assert!(
772 desc.contains("the retry fires later as a user turn into a session that has moved on"),
773 "the reason the retry is stale is pinned: {desc}",
774 );
775 }
776
777 #[test]
781 fn tool_descriptions_keep_the_any_caller_sentence() {
782 let mock = MockCronFacade::new().into_arc();
783 let slot = caller_slot();
784 let create = Create { facade: mock.clone(), slot: slot.clone() };
785 let list = List { facade: mock.clone(), slot: slot.clone() };
786 let delete = Delete { facade: mock, slot };
787 for (name, desc) in [
788 ("cron__create", create.description()),
789 ("cron__list", list.description()),
790 ("cron__delete", delete.description()),
791 ] {
792 assert!(
793 desc.contains("Any session in the project may"),
794 "{name} must keep saying who may call it: {desc}",
795 );
796 }
797 }
798
799 #[test]
804 fn cron_create_description_names_the_registering_session_as_the_fire_target() {
805 let create = Create { facade: MockCronFacade::new().into_arc(), slot: caller_slot() };
806 let desc = create.description();
807 assert!(
808 desc.contains("fires into the session that registers it"),
809 "the create description must say where the prompt lands: {desc}",
810 );
811 assert!(
812 !desc.contains("project's session"),
813 "the prompt does not land in the project's session: {desc}",
814 );
815 }
816
817 #[test]
818 fn tool_names_are_the_cron_family() {
819 let mock = MockCronFacade::new().into_arc();
825 let slot = caller_slot();
826 let create = Create { facade: mock.clone(), slot: slot.clone() };
827 let list = List { facade: mock.clone(), slot: slot.clone() };
828 let delete = Delete { facade: mock, slot };
829 assert_eq!(create.name(), "cron__create");
830 assert_eq!(list.name(), "cron__list");
831 assert_eq!(delete.name(), "cron__delete");
832 }
833}