返回 CodeWhale
automation.rs
根目录 / crates / tui / src / tools / automation.rs
1 //! Model-visible automation tools over `AutomationManager`.
2 //!
3 //! Unified surface (piagent phase B): the model sees one tool, `automation`,
4 //! with an `action` parameter routing to the per-action logic. The legacy
5 //! `automation_*` execution aliases were removed in v0.9.3.
6
7 use std::path::PathBuf;
8
9 use async_trait::async_trait;
10 use serde_json::{Value, json};
11
12 use crate::automation_manager::{
13 AUTOMATION_WATCHER_NO_REPORT_SENTINEL, AutomationDeliveryMode, AutomationStatus,
14 CreateAutomationRequest, UpdateAutomationRequest, run_now_shared,
15 };
16 use crate::tools::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
18 optional_str, optional_u64, required_str,
19 };
20
21 /// Read-only actions — these are the only ones the Plan-mode surface exposes.
22 const READ_ACTIONS: &[&str] = &["list", "read"];
23 const ALL_ACTIONS: &[&str] = &[
24 "create", "list", "read", "update", "pause", "resume", "delete", "run",
25 ];
26
27 /// Unified automation tool.
28 ///
29 /// One struct, one input schema per surface: the canonical `automation`
30 /// tool (all actions, or the read-only subset via [`AutomationTool::read_only`])
31 /// plus hidden legacy aliases carrying a `forced_action`.
32 pub struct AutomationTool {
33 name: &'static str,
34 forced_action: Option<&'static str>,
35 read_only: bool,
36 }
37
38 impl AutomationTool {
39 pub const fn new(name: &'static str) -> Self {
40 Self {
41 name,
42 forced_action: None,
43 read_only: false,
44 }
45 }
46
47 /// Plan-mode variant: only the read-only actions are advertised and routed.
48 pub const fn read_only(name: &'static str) -> Self {
49 Self {
50 name,
51 forced_action: None,
52 read_only: true,
53 }
54 }
55
56 #[cfg(test)]
57 pub const fn alias(name: &'static str, action: &'static str) -> Self {
58 Self {
59 name,
60 forced_action: Some(action),
61 read_only: false,
62 }
63 }
64
65 fn allowed_actions(&self) -> &'static [&'static str] {
66 if self.read_only {
67 READ_ACTIONS
68 } else {
69 ALL_ACTIONS
70 }
71 }
72
73 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
74 let action = match self.forced_action {
75 Some(action) => action,
76 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
77 ToolError::invalid_input(format!(
78 "automation: missing `action` (one of: {})",
79 self.allowed_actions().join(", ")
80 ))
81 })?,
82 };
83 if self.allowed_actions().contains(&action) {
84 Ok(action)
85 } else {
86 Err(ToolError::invalid_input(format!(
87 "automation: invalid action `{action}` (one of: {})",
88 self.allowed_actions().join(", ")
89 )))
90 }
91 }
92
93 fn action_is_read(action: &str) -> bool {
94 READ_ACTIONS.contains(&action)
95 }
96 }
97
98 #[async_trait]
99 impl ToolSpec for AutomationTool {
100 fn name(&self) -> &'static str {
101 self.name
102 }
103
104 fn model_visible(&self) -> bool {
105 self.forced_action.is_none()
106 }
107
108 fn description(&self) -> &'static str {
109 match self.forced_action {
110 Some("create") => {
111 "Create a durable scheduled automation. Creation requires approval. Supported schedules: FREQ=ONCE;AT=YYYY-MM-DDTHH:MM[:SS] (local time) or RFC3339, FREQ=HOURLY..., FREQ=WEEKLY..., and FREQ=CRON;EXPR=<standard 5-field local cron>. delivery_mode=watcher is for condition checks: return EXACTLY NOTHING_TO_REPORT when there is no change."
112 }
113 Some("list") => {
114 "List durable automations with status, next run, and last run timestamps."
115 }
116 Some("read") => "Read one durable automation plus recent run records.",
117 Some("update") => {
118 "Update a durable automation. Requires approval; schedules support ONCE, HOURLY, WEEKLY, and 5-field CRON forms."
119 }
120 Some("pause") => "Pause a durable automation. Requires approval.",
121 Some("resume") => "Resume a paused durable automation. Requires approval.",
122 Some("delete") => "Delete a durable automation and its run history. Requires approval.",
123 Some("run") => {
124 "Run an automation now. The run enqueues a normal durable task and returns linked task/thread/turn ids as they become available."
125 }
126 _ if self.read_only => {
127 "Inspect durable scheduled automations. Actions: \"list\" (status, next run, last run) and \"read\" (one automation plus recent run records)."
128 }
129 _ => {
130 "Manage durable scheduled automations. Actions: \"create\" (approval; schedules support ONCE, HOURLY, WEEKLY, and 5-field CRON forms; watcher mode uses EXACT NOTHING_TO_REPORT for no-change checks), \"list\", \"read\", \"update\" (approval), \"pause\" (approval), \"resume\" (approval), \"delete\" (approval), \"run\" (approval)."
131 }
132 }
133 }
134
135 fn input_schema(&self) -> Value {
136 if let Some(action) = self.forced_action {
137 return legacy_action_schema(action);
138 }
139 let actions: Vec<&str> = self.allowed_actions().to_vec();
140 let mut properties = serde_json::Map::new();
141 properties.insert(
142 "action".to_string(),
143 json!({
144 "type": "string",
145 "enum": actions,
146 "description": "Action to perform."
147 }),
148 );
149 if !self.read_only {
150 properties.insert(
151 "name".to_string(),
152 json!({ "type": "string", "description": "Automation name (action=create/update)." }),
153 );
154 properties.insert(
155 "prompt".to_string(),
156 json!({ "type": "string", "description": "Prompt for scheduled runs (action=create/update)." }),
157 );
158 properties.insert(
159 "rrule".to_string(),
160 json!({
161 "type": "string",
162 "description": "Supported: FREQ=ONCE;AT=2026-08-03T14:30 (local time or RFC3339), FREQ=HOURLY;INTERVAL=N[;BYDAY=MO,TU][;BYHOUR=9][;BYMINUTE=30], FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30, or FREQ=CRON;EXPR=*/17 * * * *. Cron uses standard 5-field local time. For HOURLY, BYHOUR/BYMINUTE choose the initial local wall-clock anchor and INTERVAL advances from that anchor; BYHOUR is not a daily-only filter. Anchored wall times skip nonexistent clock times and use the first occurrence of ambiguous clock times. (action=create/update)"
163 }),
164 );
165 properties.insert(
166 "cwds".to_string(),
167 json!({ "type": "array", "items": { "type": "string" }, "description": "Working directories for scheduled runs (action=create/update)." }),
168 );
169 properties.insert(
170 "mode".to_string(),
171 json!({ "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted. (action=create/update)" }),
172 );
173 properties.insert(
174 "allow_shell".to_string(),
175 json!({ "type": "boolean", "default": false, "description": "(action=create/update)" }),
176 );
177 properties.insert(
178 "trust_mode".to_string(),
179 json!({ "type": "boolean", "default": false, "description": "(action=create/update)" }),
180 );
181 properties.insert(
182 "auto_approve".to_string(),
183 json!({ "type": "boolean", "default": false, "description": "(action=create/update)" }),
184 );
185 properties.insert(
186 "delivery_mode".to_string(),
187 json!({
188 "type": "string",
189 "enum": ["task", "watcher"],
190 "default": "task",
191 "description": format!("Delivery mode for scheduled checks. \"task\" creates a normal durable background run. \"watcher\" is for condition-shaped prompts; when there is no change, return EXACTLY {AUTOMATION_WATCHER_NO_REPORT_SENTINEL}. (action=create/update)")
192 }),
193 );
194 properties.insert(
195 "paused".to_string(),
196 json!({ "type": "boolean", "default": false, "description": "Create the automation paused (action=create)." }),
197 );
198 properties.insert(
199 "status".to_string(),
200 json!({ "type": "string", "enum": ["active", "paused"], "description": "(action=update)" }),
201 );
202 }
203 properties.insert(
204 "automation_id".to_string(),
205 json!({ "type": "string", "description": "Target automation id (action=read/update/pause/resume/delete/run)." }),
206 );
207 properties.insert(
208 "limit".to_string(),
209 json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 50, "description": "(action=list)" }),
210 );
211 json!({
212 "type": "object",
213 "properties": properties,
214 "additionalProperties": false
215 })
216 }
217
218 fn capabilities(&self) -> Vec<ToolCapability> {
219 match self.forced_action {
220 Some(action) if Self::action_is_read(action) => vec![ToolCapability::ReadOnly],
221 // `run` executes a stored automation now; the other mutating
222 // actions schedule one to execute later, with its own prompt, cwd,
223 // and task mode. Declaring only `RequiresApproval` described the
224 // *approval* consequence and hid the *execution* one, which left
225 // every capability-derived policy — including the child execution
226 // envelope — unable to see that this family spawns agent runs.
227 Some(_) => vec![
228 ToolCapability::ExecutesCode,
229 ToolCapability::RequiresApproval,
230 ],
231 None if self.read_only => vec![ToolCapability::ReadOnly],
232 None => vec![
233 ToolCapability::ExecutesCode,
234 ToolCapability::RequiresApproval,
235 ],
236 }
237 }
238
239 fn approval_requirement(&self) -> ApprovalRequirement {
240 match self.forced_action {
241 Some(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
242 Some(_) => ApprovalRequirement::Required,
243 None if self.read_only => ApprovalRequirement::Auto,
244 None => ApprovalRequirement::Required,
245 }
246 }
247
248 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
249 match self.resolve_action(input) {
250 Ok(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
251 _ => ApprovalRequirement::Required,
252 }
253 }
254
255 fn is_read_only_for(&self, input: &Value) -> bool {
256 match self.resolve_action(input) {
257 Ok(action) => Self::action_is_read(action),
258 Err(_) => self.is_read_only(),
259 }
260 }
261
262 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
263 match self.resolve_action(&input)? {
264 "create" => self.execute_create(&input, context).await,
265 "list" => self.execute_list(&input, context).await,
266 "read" => self.execute_read(&input, context).await,
267 "update" => self.execute_update(&input, context).await,
268 "pause" => self.execute_simple(context, &input, "pause").await,
269 "resume" => self.execute_simple(context, &input, "resume").await,
270 "delete" => self.execute_simple(context, &input, "delete").await,
271 "run" => self.execute_run(&input, context).await,
272 action => Err(ToolError::invalid_input(format!(
273 "automation: invalid action `{action}`"
274 ))),
275 }
276 }
277 }
278
279 impl AutomationTool {
280 async fn execute_create(
281 &self,
282 input: &Value,
283 context: &ToolContext,
284 ) -> Result<ToolResult, ToolError> {
285 let manager = context
286 .runtime
287 .automations
288 .as_ref()
289 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
290 let manager = manager.lock().await;
291 let req = CreateAutomationRequest {
292 name: required_str(input, "name")?.to_string(),
293 prompt: required_str(input, "prompt")?.to_string(),
294 rrule: required_str(input, "rrule")?.to_string(),
295 cwds: string_array(input, "cwds")?
296 .into_iter()
297 .map(PathBuf::from)
298 .collect(),
299 mode: optional_str(input, "mode")?.map(ToString::to_string),
300 allow_shell: optional_bool_value(input, "allow_shell"),
301 trust_mode: optional_bool_value(input, "trust_mode"),
302 auto_approve: optional_bool_value(input, "auto_approve"),
303 delivery_mode: optional_delivery_mode(input)?,
304 status: Some(
305 if input
306 .get("paused")
307 .and_then(Value::as_bool)
308 .unwrap_or(false)
309 {
310 AutomationStatus::Paused
311 } else {
312 AutomationStatus::Active
313 },
314 ),
315 };
316 let automation = manager
317 .create_automation(req)
318 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
319 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
320 }
321
322 async fn execute_list(
323 &self,
324 input: &Value,
325 context: &ToolContext,
326 ) -> Result<ToolResult, ToolError> {
327 let manager = context
328 .runtime
329 .automations
330 .as_ref()
331 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
332 let manager = manager.lock().await;
333 let mut automations = manager
334 .list_automations()
335 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
336 automations.truncate(optional_u64(input, "limit", 50)?.clamp(1, 100) as usize);
337 ToolResult::json(&automations).map_err(|e| ToolError::execution_failed(e.to_string()))
338 }
339
340 async fn execute_read(
341 &self,
342 input: &Value,
343 context: &ToolContext,
344 ) -> Result<ToolResult, ToolError> {
345 let manager = context
346 .runtime
347 .automations
348 .as_ref()
349 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
350 let manager = manager.lock().await;
351 let id = required_str(input, "automation_id")?;
352 let automation = manager
353 .get_automation(id)
354 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
355 let runs = manager
356 .list_runs(id, Some(20))
357 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
358 ToolResult::json(&json!({ "automation": automation, "recent_runs": runs }))
359 .map_err(|e| ToolError::execution_failed(e.to_string()))
360 }
361
362 async fn execute_update(
363 &self,
364 input: &Value,
365 context: &ToolContext,
366 ) -> Result<ToolResult, ToolError> {
367 let manager = context
368 .runtime
369 .automations
370 .as_ref()
371 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
372 let manager = manager.lock().await;
373 let status = optional_str(input, "status")?
374 .map(parse_automation_status)
375 .transpose()?;
376 let req = UpdateAutomationRequest {
377 name: optional_str(input, "name")?.map(ToString::to_string),
378 prompt: optional_str(input, "prompt")?.map(ToString::to_string),
379 rrule: optional_str(input, "rrule")?.map(ToString::to_string),
380 cwds: if input.get("cwds").is_some() {
381 Some(
382 string_array(input, "cwds")?
383 .into_iter()
384 .map(PathBuf::from)
385 .collect(),
386 )
387 } else {
388 None
389 },
390 mode: optional_str(input, "mode")?.map(ToString::to_string),
391 allow_shell: optional_bool_value(input, "allow_shell"),
392 trust_mode: optional_bool_value(input, "trust_mode"),
393 auto_approve: optional_bool_value(input, "auto_approve"),
394 delivery_mode: optional_delivery_mode(input)?,
395 status,
396 };
397 let automation = manager
398 .update_automation(required_str(input, "automation_id")?, req)
399 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
400 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
401 }
402
403 /// pause / resume / delete share the same shape: one id in, automation out.
404 async fn execute_simple(
405 &self,
406 context: &ToolContext,
407 input: &Value,
408 action: &str,
409 ) -> Result<ToolResult, ToolError> {
410 let manager = context
411 .runtime
412 .automations
413 .as_ref()
414 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
415 let manager = manager.lock().await;
416 let automation = match action {
417 "pause" => manager.pause_automation(required_str(input, "automation_id")?),
418 "resume" => manager.resume_automation(required_str(input, "automation_id")?),
419 "delete" => manager.delete_automation(required_str(input, "automation_id")?),
420 _ => unreachable!("execute_simple only routes pause/resume/delete"),
421 }
422 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
423 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
424 }
425
426 async fn execute_run(
427 &self,
428 input: &Value,
429 context: &ToolContext,
430 ) -> Result<ToolResult, ToolError> {
431 let manager = context
432 .runtime
433 .automations
434 .as_ref()
435 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
436 let task_manager = context
437 .runtime
438 .task_manager
439 .as_ref()
440 .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?;
441 // run_now_shared handles its own lock phases so the manager mutex is
442 // never held across the task-manager await.
443 let run = run_now_shared(manager, required_str(input, "automation_id")?, task_manager)
444 .await
445 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
446 ToolResult::json(&run).map_err(|e| ToolError::execution_failed(e.to_string()))
447 }
448 }
449
450 /// The exact schema the legacy per-action tool exposed, kept so hidden alias
451 /// registrations report an identical contract to the pre-unification tools.
452 fn legacy_action_schema(action: &str) -> Value {
453 match action {
454 "create" => json!({
455 "type": "object",
456 "properties": {
457 "name": { "type": "string" },
458 "prompt": { "type": "string" },
459 "rrule": {
460 "type": "string",
461 "description": "Supported: FREQ=ONCE;AT=2026-08-03T14:30 (local time or RFC3339), FREQ=HOURLY;INTERVAL=N[;BYDAY=MO,TU][;BYHOUR=9][;BYMINUTE=30], FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30, or FREQ=CRON;EXPR=*/17 * * * *. Cron uses standard 5-field local time. For HOURLY, BYHOUR/BYMINUTE choose the initial local wall-clock anchor and INTERVAL advances from that anchor; BYHOUR is not a daily-only filter. Anchored wall times skip nonexistent clock times and use the first occurrence of ambiguous clock times."
462 },
463 "cwds": { "type": "array", "items": { "type": "string" } },
464 "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
465 "allow_shell": { "type": "boolean", "default": false },
466 "trust_mode": { "type": "boolean", "default": false },
467 "auto_approve": { "type": "boolean", "default": false },
468 "delivery_mode": {
469 "type": "string",
470 "enum": ["task", "watcher"],
471 "default": "task",
472 "description": "Delivery mode. watcher prompts must return EXACTLY NOTHING_TO_REPORT when there is no change."
473 },
474 "paused": { "type": "boolean", "default": false }
475 },
476 "required": ["name", "prompt", "rrule"],
477 "additionalProperties": false
478 }),
479 "list" => json!({
480 "type": "object",
481 "properties": {
482 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }
483 },
484 "additionalProperties": false
485 }),
486 "update" => json!({
487 "type": "object",
488 "properties": {
489 "automation_id": { "type": "string" },
490 "name": { "type": "string" },
491 "prompt": { "type": "string" },
492 "rrule": { "type": "string" },
493 "cwds": { "type": "array", "items": { "type": "string" } },
494 "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
495 "allow_shell": { "type": "boolean" },
496 "trust_mode": { "type": "boolean" },
497 "auto_approve": { "type": "boolean" },
498 "delivery_mode": { "type": "string", "enum": ["task", "watcher"] },
499 "status": { "type": "string", "enum": ["active", "paused"] }
500 },
501 "required": ["automation_id"],
502 "additionalProperties": false
503 }),
504 // read / pause / resume / delete / run share the id-only schema.
505 _ => automation_id_schema(true),
506 }
507 }
508
509 fn automation_id_schema(require_id: bool) -> Value {
510 let mut schema = json!({
511 "type": "object",
512 "properties": {
513 "automation_id": { "type": "string" }
514 },
515 "additionalProperties": false
516 });
517 if require_id {
518 schema["required"] = json!(["automation_id"]);
519 }
520 schema
521 }
522
523 fn string_array(input: &Value, field: &str) -> Result<Vec<String>, ToolError> {
524 Ok(input
525 .get(field)
526 .and_then(Value::as_array)
527 .map(|items| {
528 items
529 .iter()
530 .filter_map(Value::as_str)
531 .map(ToString::to_string)
532 .collect::<Vec<_>>()
533 })
534 .unwrap_or_default())
535 }
536
537 fn optional_bool_value(input: &Value, field: &str) -> Option<bool> {
538 input.get(field).and_then(Value::as_bool)
539 }
540
541 /// Parse an `automation_update` status. #5123-class: unknown statuses used to
542 /// coerce to Active — the opposite of pause intent, and run-scheduling.
543 fn parse_automation_status(value: &str) -> Result<AutomationStatus, ToolError> {
544 match value {
545 "active" => Ok(AutomationStatus::Active),
546 "paused" => Ok(AutomationStatus::Paused),
547 other => Err(ToolError::invalid_input(format!(
548 "unknown automation status '{other}'; expected 'active' or 'paused'"
549 ))),
550 }
551 }
552
553 fn optional_delivery_mode(input: &Value) -> Result<Option<AutomationDeliveryMode>, ToolError> {
554 match optional_str(input, "delivery_mode")? {
555 None => Ok(None),
556 Some("task") => Ok(Some(AutomationDeliveryMode::Task)),
557 Some("watcher") => Ok(Some(AutomationDeliveryMode::Watcher)),
558 Some(other) => Err(ToolError::invalid_input(format!(
559 "automation: invalid delivery_mode `{other}` (expected task or watcher)"
560 ))),
561 }
562 }
563
564 #[cfg(test)]
565 mod tests {
566 use super::*;
567 use crate::tools::spec::ToolSpec;
568
569 #[test]
570 fn create_schema_exposes_rrule() {
571 let schema = AutomationTool::alias("automation_create", "create").input_schema();
572 assert!(schema["properties"]["rrule"].is_object());
573 assert_eq!(schema["required"][0], "name");
574 }
575
576 #[test]
577 fn update_status_rejects_unknown_values_instead_of_coercing_to_active() {
578 assert!(matches!(
579 parse_automation_status("active"),
580 Ok(AutomationStatus::Active)
581 ));
582 assert!(matches!(
583 parse_automation_status("paused"),
584 Ok(AutomationStatus::Paused)
585 ));
586 for bad in ["pause", "disabled", "off", "stopped", ""] {
587 let err = parse_automation_status(bad).expect_err("must not coerce");
588 assert!(
589 err.to_string().contains("expected 'active' or 'paused'"),
590 "{err}"
591 );
592 }
593 }
594
595 #[test]
596 fn create_schema_auto_approve_defaults_to_false() {
597 let schema = AutomationTool::alias("automation_create", "create").input_schema();
598 let auto_approve = &schema["properties"]["auto_approve"];
599 assert_eq!(auto_approve["type"], "boolean");
600 assert_eq!(auto_approve["default"], false);
601 }
602
603 #[test]
604 fn create_schema_exposes_delivery_mode_and_new_schedule_forms() {
605 let schema = AutomationTool::alias("automation_create", "create").input_schema();
606 assert_eq!(
607 schema["properties"]["delivery_mode"]["enum"],
608 json!(["task", "watcher"])
609 );
610 let description = schema["properties"]["rrule"]["description"]
611 .as_str()
612 .expect("rrule description");
613 assert!(description.contains("FREQ=ONCE"));
614 assert!(description.contains("FREQ=CRON"));
615 }
616
617 #[test]
618 fn canonical_schema_lists_all_actions_and_union_fields() {
619 let schema = AutomationTool::new("automation").input_schema();
620 let actions = schema["properties"]["action"]["enum"]
621 .as_array()
622 .expect("action enum");
623 for action in [
624 "create", "list", "read", "update", "pause", "resume", "delete", "run",
625 ] {
626 assert!(
627 actions.iter().any(|value| value.as_str() == Some(action)),
628 "canonical schema must offer action {action}"
629 );
630 }
631 for field in [
632 "name",
633 "prompt",
634 "rrule",
635 "delivery_mode",
636 "automation_id",
637 "limit",
638 ] {
639 assert!(
640 schema["properties"][field].is_object(),
641 "canonical schema must carry union field {field}"
642 );
643 }
644 assert_eq!(schema["additionalProperties"], json!(false));
645 }
646
647 #[test]
648 fn read_only_variant_only_offers_read_actions() {
649 let tool = AutomationTool::read_only("automation");
650 let schema = tool.input_schema();
651 let actions = schema["properties"]["action"]["enum"]
652 .as_array()
653 .expect("action enum");
654 assert_eq!(actions, &vec![json!("list"), json!("read")]);
655 assert!(!schema["properties"]["rrule"].is_object());
656 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
657 assert!(tool.is_read_only());
658 }
659
660 #[test]
661 fn aliases_hide_from_model_and_force_action() {
662 let create = AutomationTool::alias("automation_create", "create");
663 assert!(!create.model_visible());
664 assert_eq!(create.name(), "automation_create");
665 assert_eq!(create.approval_requirement(), ApprovalRequirement::Required);
666
667 let list = AutomationTool::alias("automation_list", "list");
668 assert!(!list.model_visible());
669 assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto);
670 assert!(list.is_read_only_for(&json!({})));
671
672 let canonical = AutomationTool::new("automation");
673 assert!(canonical.model_visible());
674 // Approval routing stays per action: read actions auto, writes required.
675 assert_eq!(
676 canonical.approval_requirement_for(&json!({"action": "list"})),
677 ApprovalRequirement::Auto
678 );
679 assert_eq!(
680 canonical.approval_requirement_for(&json!({"action": "delete"})),
681 ApprovalRequirement::Required
682 );
683 assert!(canonical.is_read_only_for(&json!({"action": "read"})));
684 assert!(!canonical.is_read_only_for(&json!({"action": "create"})));
685 }
686
687 #[test]
688 fn canonical_rejects_unknown_or_missing_action() {
689 let tool = AutomationTool::new("automation");
690 let err = tool
691 .resolve_action(&json!({}))
692 .expect_err("missing action must fail");
693 assert!(err.to_string().contains("missing `action`"));
694 let err = tool
695 .resolve_action(&json!({"action": "explode"}))
696 .expect_err("unknown action must fail");
697 assert!(err.to_string().contains("invalid action"));
698
699 let read_only = AutomationTool::read_only("automation");
700 let err = read_only
701 .resolve_action(&json!({"action": "delete"}))
702 .expect_err("read-only surface must reject write actions");
703 assert!(err.to_string().contains("invalid action"));
704 }
705 }
706
706 lines RUST