Skip to main content

zeroclaw_tools/
linkedin.rs

1use super::linkedin_client::{ImageGenerator, LinkedInClient};
2use async_trait::async_trait;
3use serde_json::json;
4use std::path::PathBuf;
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8use zeroclaw_config::schema::{LinkedInContentConfig, LinkedInImageConfig};
9
10pub struct LinkedInTool {
11    security: Arc<SecurityPolicy>,
12    workspace_dir: PathBuf,
13    api_version: String,
14    content_config: LinkedInContentConfig,
15    image_config: LinkedInImageConfig,
16}
17
18impl LinkedInTool {
19    pub fn new(
20        security: Arc<SecurityPolicy>,
21        workspace_dir: PathBuf,
22        api_version: String,
23        content_config: LinkedInContentConfig,
24        image_config: LinkedInImageConfig,
25    ) -> Self {
26        Self {
27            security,
28            workspace_dir,
29            api_version,
30            content_config,
31            image_config,
32        }
33    }
34
35    fn is_write_action(action: &str) -> bool {
36        matches!(action, "create_post" | "comment" | "react" | "delete_post")
37    }
38
39    fn build_content_strategy_summary(&self) -> String {
40        let c = &self.content_config;
41        let mut parts = Vec::new();
42
43        if !c.persona.is_empty() {
44            parts.push(format!("## Persona\n{}", c.persona));
45        }
46
47        if !c.topics.is_empty() {
48            parts.push(format!("## Topics\n{}", c.topics.join(", ")));
49        }
50
51        if !c.rss_feeds.is_empty() {
52            let feeds: Vec<String> = c.rss_feeds.iter().map(|f| format!("- {f}")).collect();
53            parts.push(format!(
54                "## RSS Feeds (fetch titles only for inspiration)\n{}",
55                feeds.join("\n")
56            ));
57        }
58
59        if !c.github_users.is_empty() {
60            parts.push(format!(
61                "## GitHub Users (check public activity)\n{}",
62                c.github_users.join(", ")
63            ));
64        }
65
66        if !c.github_repos.is_empty() {
67            let repos: Vec<String> = c.github_repos.iter().map(|r| format!("- {r}")).collect();
68            parts.push(format!(
69                "## GitHub Repos (highlight project work)\n{}",
70                repos.join("\n")
71            ));
72        }
73
74        if !c.instructions.is_empty() {
75            parts.push(format!("## Posting Instructions\n{}", c.instructions));
76        }
77
78        if parts.is_empty() {
79            return "No content strategy configured. Add [linkedin.content] settings to config.toml with rss_feeds, github_repos, persona, topics, and instructions.".to_string();
80        }
81
82        parts.join("\n\n")
83    }
84}
85
86#[async_trait]
87impl Tool for LinkedInTool {
88    fn name(&self) -> &str {
89        "linkedin"
90    }
91
92    fn description(&self) -> &str {
93        "Manage LinkedIn: create posts, list your posts, comment, react, delete posts, view engagement, get profile info, and read the configured content strategy. Requires LINKEDIN_* credentials in .env file."
94    }
95
96    fn parameters_schema(&self) -> serde_json::Value {
97        json!({
98            "type": "object",
99            "properties": {
100                "action": {
101                    "type": "string",
102                    "enum": [
103                        "create_post",
104                        "list_posts",
105                        "comment",
106                        "react",
107                        "delete_post",
108                        "get_engagement",
109                        "get_profile",
110                        "get_content_strategy"
111                    ],
112                    "description": "The LinkedIn action to perform"
113                },
114                "text": {
115                    "type": "string",
116                    "description": "Post or comment text content"
117                },
118                "visibility": {
119                    "type": "string",
120                    "enum": ["PUBLIC", "CONNECTIONS"],
121                    "description": "Post visibility (default: PUBLIC)"
122                },
123                "article_url": {
124                    "type": "string",
125                    "description": "URL for link preview in a post"
126                },
127                "article_title": {
128                    "type": "string",
129                    "description": "Title for the article (requires article_url)"
130                },
131                "post_id": {
132                    "type": "string",
133                    "description": "LinkedIn post URN identifier"
134                },
135                "reaction_type": {
136                    "type": "string",
137                    "enum": ["LIKE", "CELEBRATE", "SUPPORT", "LOVE", "INSIGHTFUL", "FUNNY"],
138                    "description": "Type of reaction to add to a post"
139                },
140                "count": {
141                    "type": "integer",
142                    "description": "Number of posts to retrieve (default 10, max 50)"
143                },
144                "generate_image": {
145                    "type": "boolean",
146                    "description": "Generate an AI image for the post (requires [linkedin.image] config). Falls back to branded SVG card if all model_providers fail."
147                },
148                "image_prompt": {
149                    "type": "string",
150                    "description": "Custom prompt for image generation. If omitted, a prompt is derived from the post text."
151                },
152                "scheduled_at": {
153                    "type": "string",
154                    "description": "Schedule the post for future publication. ISO 8601 / RFC 3339 timestamp, e.g. '2026-03-17T08:00:00Z'. The post is saved as a draft with scheduledPublishTime on LinkedIn."
155                }
156            },
157            "required": ["action"]
158        })
159    }
160
161    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
162        let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
163            ::zeroclaw_log::record!(
164                WARN,
165                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
166                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
167                    .with_attrs(::serde_json::json!({"param": "action"})),
168                "linkedin: missing action parameter"
169            );
170            anyhow::Error::msg("Missing required 'action' parameter")
171        })?;
172
173        // Write actions require autonomy check
174        if Self::is_write_action(action) && !self.security.can_act() {
175            return Ok(ToolResult {
176                success: false,
177                output: String::new(),
178                error: Some("Action blocked: autonomy is read-only".into()),
179            });
180        }
181
182        // All actions are rate-limited
183        if !self.security.record_action() {
184            return Ok(ToolResult {
185                success: false,
186                output: String::new(),
187                error: Some("Action blocked: rate limit exceeded".into()),
188            });
189        }
190
191        let client = LinkedInClient::new(self.workspace_dir.clone(), self.api_version.clone());
192
193        match action {
194            "get_content_strategy" => {
195                let strategy = self.build_content_strategy_summary();
196                return Ok(ToolResult {
197                    success: true,
198                    output: strategy,
199                    error: None,
200                });
201            }
202            "create_post" => {
203                let text = match args.get("text").and_then(|v| v.as_str()).map(str::trim) {
204                    Some(t) if !t.is_empty() => t.to_string(),
205                    _ => {
206                        return Ok(ToolResult {
207                            success: false,
208                            output: String::new(),
209                            error: Some("Missing required 'text' parameter for create_post".into()),
210                        });
211                    }
212                };
213
214                let visibility = args
215                    .get("visibility")
216                    .and_then(|v| v.as_str())
217                    .unwrap_or("PUBLIC");
218
219                let generate_image = args
220                    .get("generate_image")
221                    .and_then(|v| v.as_bool())
222                    .unwrap_or(false);
223
224                let article_url = args.get("article_url").and_then(|v| v.as_str());
225                let article_title = args.get("article_title").and_then(|v| v.as_str());
226                let scheduled_at = args.get("scheduled_at").and_then(|v| v.as_str());
227
228                if article_title.is_some() && article_url.is_none() {
229                    return Ok(ToolResult {
230                        success: false,
231                        output: String::new(),
232                        error: Some("'article_title' requires 'article_url' to be provided".into()),
233                    });
234                }
235
236                // Image generation flow
237                if generate_image && self.image_config.enabled {
238                    let image_prompt = args
239                        .get("image_prompt")
240                        .and_then(|v| v.as_str())
241                        .map(String::from)
242                        .unwrap_or_else(|| {
243                            format!(
244                                "Professional, modern illustration for a LinkedIn post about: {}",
245                                if text.len() > 200 {
246                                    &text[..200]
247                                } else {
248                                    &text
249                                }
250                            )
251                        });
252
253                    let generator =
254                        ImageGenerator::new(self.image_config.clone(), self.workspace_dir.clone());
255
256                    match generator.generate(&image_prompt).await {
257                        Ok(image_path) => {
258                            let image_bytes = tokio::fs::read(&image_path).await?;
259                            let creds = client.get_credentials().await?;
260                            let image_urn = client
261                                .upload_image(&image_bytes, &creds.access_token, &creds.person_id)
262                                .await?;
263
264                            let post_id = client
265                                .create_post_with_image(&text, visibility, &image_urn, scheduled_at)
266                                .await?;
267
268                            // Clean up temp file
269                            let _ = ImageGenerator::cleanup(&image_path).await;
270
271                            let action_word = if scheduled_at.is_some() {
272                                "scheduled"
273                            } else {
274                                "published"
275                            };
276                            return Ok(ToolResult {
277                                success: true,
278                                output: format!(
279                                    "Post {action_word} with image. Post ID: {post_id}, Image: {image_urn}"
280                                ),
281                                error: None,
282                            });
283                        }
284                        Err(e) => {
285                            // Image generation failed entirely — post without image
286                            ::zeroclaw_log::record!(
287                                WARN,
288                                ::zeroclaw_log::Event::new(
289                                    module_path!(),
290                                    ::zeroclaw_log::Action::Note
291                                )
292                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
293                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
294                                "Image generation failed, posting without image"
295                            );
296                        }
297                    }
298                }
299
300                let post_id = client
301                    .create_post(&text, visibility, article_url, article_title, scheduled_at)
302                    .await?;
303
304                let action_word = if scheduled_at.is_some() {
305                    "scheduled"
306                } else {
307                    "published"
308                };
309                Ok(ToolResult {
310                    success: true,
311                    output: format!("Post {action_word} successfully. Post ID: {post_id}"),
312                    error: None,
313                })
314            }
315
316            "list_posts" => {
317                let count = args
318                    .get("count")
319                    .and_then(|v| v.as_u64())
320                    .unwrap_or(10)
321                    .clamp(1, 50) as usize;
322
323                let posts = client.list_posts(count).await?;
324
325                Ok(ToolResult {
326                    success: true,
327                    output: serde_json::to_string(&posts)?,
328                    error: None,
329                })
330            }
331
332            "comment" => {
333                let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
334                    Some(id) if !id.is_empty() => id,
335                    _ => {
336                        return Ok(ToolResult {
337                            success: false,
338                            output: String::new(),
339                            error: Some("Missing required 'post_id' parameter for comment".into()),
340                        });
341                    }
342                };
343
344                let text = match args.get("text").and_then(|v| v.as_str()).map(str::trim) {
345                    Some(t) if !t.is_empty() => t.to_string(),
346                    _ => {
347                        return Ok(ToolResult {
348                            success: false,
349                            output: String::new(),
350                            error: Some("Missing required 'text' parameter for comment".into()),
351                        });
352                    }
353                };
354
355                let comment_id = client.add_comment(post_id, &text).await?;
356
357                Ok(ToolResult {
358                    success: true,
359                    output: format!("Comment posted successfully. Comment ID: {comment_id}"),
360                    error: None,
361                })
362            }
363
364            "react" => {
365                let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
366                    Some(id) if !id.is_empty() => id,
367                    _ => {
368                        return Ok(ToolResult {
369                            success: false,
370                            output: String::new(),
371                            error: Some("Missing required 'post_id' parameter for react".into()),
372                        });
373                    }
374                };
375
376                let reaction_type = match args.get("reaction_type").and_then(|v| v.as_str()) {
377                    Some(rt) if !rt.is_empty() => rt,
378                    _ => {
379                        return Ok(ToolResult {
380                            success: false,
381                            output: String::new(),
382                            error: Some(
383                                "Missing required 'reaction_type' parameter for react".into(),
384                            ),
385                        });
386                    }
387                };
388
389                client.add_reaction(post_id, reaction_type).await?;
390
391                Ok(ToolResult {
392                    success: true,
393                    output: format!("Reaction '{reaction_type}' added to post {post_id}"),
394                    error: None,
395                })
396            }
397
398            "delete_post" => {
399                let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
400                    Some(id) if !id.is_empty() => id,
401                    _ => {
402                        return Ok(ToolResult {
403                            success: false,
404                            output: String::new(),
405                            error: Some(
406                                "Missing required 'post_id' parameter for delete_post".into(),
407                            ),
408                        });
409                    }
410                };
411
412                client.delete_post(post_id).await?;
413
414                Ok(ToolResult {
415                    success: true,
416                    output: format!("Post {post_id} deleted successfully"),
417                    error: None,
418                })
419            }
420
421            "get_engagement" => {
422                let post_id = match args.get("post_id").and_then(|v| v.as_str()) {
423                    Some(id) if !id.is_empty() => id,
424                    _ => {
425                        return Ok(ToolResult {
426                            success: false,
427                            output: String::new(),
428                            error: Some(
429                                "Missing required 'post_id' parameter for get_engagement".into(),
430                            ),
431                        });
432                    }
433                };
434
435                let engagement = client.get_engagement(post_id).await?;
436
437                Ok(ToolResult {
438                    success: true,
439                    output: serde_json::to_string(&engagement)?,
440                    error: None,
441                })
442            }
443
444            "get_profile" => {
445                let profile = client.get_profile().await?;
446
447                Ok(ToolResult {
448                    success: true,
449                    output: serde_json::to_string(&profile)?,
450                    error: None,
451                })
452            }
453
454            unknown => Ok(ToolResult {
455                success: false,
456                output: String::new(),
457                error: Some(format!("Unknown action: '{unknown}'")),
458            }),
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use zeroclaw_config::autonomy::AutonomyLevel;
467
468    fn test_security(level: AutonomyLevel, max_actions_per_hour: u32) -> Arc<SecurityPolicy> {
469        Arc::new(SecurityPolicy {
470            autonomy: level,
471            max_actions_per_hour,
472            workspace_dir: std::env::temp_dir(),
473            ..SecurityPolicy::default()
474        })
475    }
476
477    fn make_tool(level: AutonomyLevel, max_actions: u32) -> LinkedInTool {
478        LinkedInTool::new(
479            test_security(level, max_actions),
480            PathBuf::from("/tmp"),
481            "202602".to_string(),
482            LinkedInContentConfig::default(),
483            LinkedInImageConfig::default(),
484        )
485    }
486
487    #[test]
488    fn tool_name() {
489        let tool = make_tool(AutonomyLevel::Full, 100);
490        assert_eq!(tool.name(), "linkedin");
491    }
492
493    #[test]
494    fn tool_description() {
495        let tool = make_tool(AutonomyLevel::Full, 100);
496        assert!(!tool.description().is_empty());
497        assert!(tool.description().contains("LinkedIn"));
498    }
499
500    #[test]
501    fn parameters_schema_has_required_action() {
502        let tool = make_tool(AutonomyLevel::Full, 100);
503        let schema = tool.parameters_schema();
504        assert_eq!(schema["type"], "object");
505        let required = schema["required"].as_array().unwrap();
506        assert!(required.contains(&json!("action")));
507    }
508
509    #[test]
510    fn parameters_schema_has_all_properties() {
511        let tool = make_tool(AutonomyLevel::Full, 100);
512        let schema = tool.parameters_schema();
513        let props = &schema["properties"];
514        assert!(props.get("action").is_some());
515        assert!(props.get("text").is_some());
516        assert!(props.get("visibility").is_some());
517        assert!(props.get("article_url").is_some());
518        assert!(props.get("article_title").is_some());
519        assert!(props.get("post_id").is_some());
520        assert!(props.get("reaction_type").is_some());
521        assert!(props.get("count").is_some());
522        assert!(props.get("generate_image").is_some());
523        assert!(props.get("image_prompt").is_some());
524    }
525
526    #[tokio::test]
527    async fn write_actions_blocked_in_readonly_mode() {
528        let tool = make_tool(AutonomyLevel::ReadOnly, 100);
529
530        for action in &["create_post", "comment", "react", "delete_post"] {
531            let result = tool
532                .execute(json!({
533                    "action": action,
534                    "text": "hello",
535                    "post_id": "urn:li:share:123",
536                    "reaction_type": "LIKE"
537                }))
538                .await
539                .unwrap();
540            assert!(
541                !result.success,
542                "Action '{action}' should be blocked in read-only mode"
543            );
544            assert!(
545                result.error.as_ref().unwrap().contains("read-only"),
546                "Action '{action}' error should mention read-only"
547            );
548        }
549    }
550
551    #[tokio::test]
552    async fn write_actions_blocked_by_rate_limit() {
553        let tool = make_tool(AutonomyLevel::Full, 0);
554
555        for action in &["create_post", "comment", "react", "delete_post"] {
556            let result = tool
557                .execute(json!({
558                    "action": action,
559                    "text": "hello",
560                    "post_id": "urn:li:share:123",
561                    "reaction_type": "LIKE"
562                }))
563                .await
564                .unwrap();
565            assert!(
566                !result.success,
567                "Action '{action}' should be blocked by rate limit"
568            );
569            assert!(
570                result.error.as_ref().unwrap().contains("rate limit"),
571                "Action '{action}' error should mention rate limit"
572            );
573        }
574    }
575
576    #[tokio::test]
577    async fn read_actions_not_blocked_in_readonly_mode() {
578        // Read actions skip can_act() but still go through record_action().
579        // With rate limit > 0, they should pass security checks and only fail
580        // at the client level (no .env file).
581        let tool = make_tool(AutonomyLevel::ReadOnly, 100);
582
583        for action in &["list_posts", "get_engagement", "get_profile"] {
584            let result = tool
585                .execute(json!({
586                    "action": action,
587                    "post_id": "urn:li:share:123"
588                }))
589                .await;
590            // These will fail at the client level (no .env), but they should NOT
591            // return a read-only security error.
592            match result {
593                Ok(r) => {
594                    if !r.success {
595                        assert!(
596                            !r.error.as_ref().unwrap().contains("read-only"),
597                            "Read action '{action}' should not be blocked by read-only mode"
598                        );
599                    }
600                }
601                Err(e) => {
602                    // Client-level error (no .env) is expected and acceptable
603                    let msg = e.to_string();
604                    assert!(
605                        !msg.contains("read-only"),
606                        "Read action '{action}' should not be blocked by read-only mode"
607                    );
608                }
609            }
610        }
611    }
612
613    #[tokio::test]
614    async fn read_actions_blocked_by_rate_limit() {
615        let tool = make_tool(AutonomyLevel::ReadOnly, 0);
616
617        for action in &["list_posts", "get_engagement", "get_profile"] {
618            let result = tool
619                .execute(json!({
620                    "action": action,
621                    "post_id": "urn:li:share:123"
622                }))
623                .await
624                .unwrap();
625            assert!(
626                !result.success,
627                "Read action '{action}' should be rate-limited"
628            );
629            assert!(
630                result.error.as_ref().unwrap().contains("rate limit"),
631                "Read action '{action}' error should mention rate limit"
632            );
633        }
634    }
635
636    #[tokio::test]
637    async fn create_post_requires_text() {
638        let tool = make_tool(AutonomyLevel::Full, 100);
639
640        let result = tool
641            .execute(json!({"action": "create_post"}))
642            .await
643            .unwrap();
644        assert!(!result.success);
645        assert!(result.error.as_ref().unwrap().contains("text"));
646    }
647
648    #[tokio::test]
649    async fn create_post_rejects_empty_text() {
650        let tool = make_tool(AutonomyLevel::Full, 100);
651
652        let result = tool
653            .execute(json!({"action": "create_post", "text": "   "}))
654            .await
655            .unwrap();
656        assert!(!result.success);
657        assert!(result.error.as_ref().unwrap().contains("text"));
658    }
659
660    #[tokio::test]
661    async fn article_title_without_url_rejected() {
662        let tool = make_tool(AutonomyLevel::Full, 100);
663
664        let result = tool
665            .execute(json!({
666                "action": "create_post",
667                "text": "Hello world",
668                "article_title": "My Article"
669            }))
670            .await
671            .unwrap();
672        assert!(!result.success);
673        assert!(result.error.as_ref().unwrap().contains("article_url"));
674    }
675
676    #[tokio::test]
677    async fn comment_requires_post_id() {
678        let tool = make_tool(AutonomyLevel::Full, 100);
679
680        let result = tool
681            .execute(json!({"action": "comment", "text": "Nice post!"}))
682            .await
683            .unwrap();
684        assert!(!result.success);
685        assert!(result.error.as_ref().unwrap().contains("post_id"));
686    }
687
688    #[tokio::test]
689    async fn comment_requires_text() {
690        let tool = make_tool(AutonomyLevel::Full, 100);
691
692        let result = tool
693            .execute(json!({"action": "comment", "post_id": "urn:li:share:123"}))
694            .await
695            .unwrap();
696        assert!(!result.success);
697        assert!(result.error.as_ref().unwrap().contains("text"));
698    }
699
700    #[tokio::test]
701    async fn react_requires_post_id() {
702        let tool = make_tool(AutonomyLevel::Full, 100);
703
704        let result = tool
705            .execute(json!({"action": "react", "reaction_type": "LIKE"}))
706            .await
707            .unwrap();
708        assert!(!result.success);
709        assert!(result.error.as_ref().unwrap().contains("post_id"));
710    }
711
712    #[tokio::test]
713    async fn react_requires_reaction_type() {
714        let tool = make_tool(AutonomyLevel::Full, 100);
715
716        let result = tool
717            .execute(json!({"action": "react", "post_id": "urn:li:share:123"}))
718            .await
719            .unwrap();
720        assert!(!result.success);
721        assert!(result.error.as_ref().unwrap().contains("reaction_type"));
722    }
723
724    #[tokio::test]
725    async fn delete_post_requires_post_id() {
726        let tool = make_tool(AutonomyLevel::Full, 100);
727
728        let result = tool
729            .execute(json!({"action": "delete_post"}))
730            .await
731            .unwrap();
732        assert!(!result.success);
733        assert!(result.error.as_ref().unwrap().contains("post_id"));
734    }
735
736    #[tokio::test]
737    async fn get_engagement_requires_post_id() {
738        let tool = make_tool(AutonomyLevel::Full, 100);
739
740        let result = tool
741            .execute(json!({"action": "get_engagement"}))
742            .await
743            .unwrap();
744        assert!(!result.success);
745        assert!(result.error.as_ref().unwrap().contains("post_id"));
746    }
747
748    #[tokio::test]
749    async fn unknown_action_returns_error() {
750        let tool = make_tool(AutonomyLevel::Full, 100);
751
752        let result = tool
753            .execute(json!({"action": "send_message"}))
754            .await
755            .unwrap();
756        assert!(!result.success);
757        assert!(result.error.as_ref().unwrap().contains("Unknown action"));
758        assert!(result.error.as_ref().unwrap().contains("send_message"));
759    }
760
761    #[tokio::test]
762    async fn get_content_strategy_returns_config() {
763        let content = LinkedInContentConfig {
764            rss_feeds: vec!["https://medium.com/feed/tag/rust".into()],
765            github_users: vec!["rareba".into()],
766            github_repos: vec!["zeroclaw-labs/zeroclaw".into()],
767            topics: vec!["cybersecurity".into(), "Rust".into()],
768            persona: "Security engineer and Rust developer".into(),
769            instructions: "Write concise posts with hashtags".into(),
770        };
771        let tool = LinkedInTool::new(
772            test_security(AutonomyLevel::Full, 100),
773            PathBuf::from("/tmp"),
774            "202602".to_string(),
775            content,
776            LinkedInImageConfig::default(),
777        );
778
779        let result = tool
780            .execute(json!({"action": "get_content_strategy"}))
781            .await
782            .unwrap();
783        assert!(result.success);
784        assert!(result.output.contains("Security engineer"));
785        assert!(result.output.contains("cybersecurity"));
786        assert!(result.output.contains("medium.com"));
787        assert!(result.output.contains("zeroclaw-labs/zeroclaw"));
788        assert!(result.output.contains("rareba"));
789        assert!(result.output.contains("Write concise posts"));
790    }
791
792    #[tokio::test]
793    async fn get_content_strategy_empty_config_shows_hint() {
794        let tool = make_tool(AutonomyLevel::Full, 100);
795
796        let result = tool
797            .execute(json!({"action": "get_content_strategy"}))
798            .await
799            .unwrap();
800        assert!(result.success);
801        assert!(result.output.contains("No content strategy configured"));
802    }
803
804    #[tokio::test]
805    async fn get_content_strategy_not_rate_limited_as_write() {
806        // get_content_strategy is a read action and should work in read-only mode
807        let tool = make_tool(AutonomyLevel::ReadOnly, 100);
808
809        let result = tool
810            .execute(json!({"action": "get_content_strategy"}))
811            .await
812            .unwrap();
813        assert!(result.success);
814    }
815
816    #[test]
817    fn parameters_schema_includes_get_content_strategy() {
818        let tool = make_tool(AutonomyLevel::Full, 100);
819        let schema = tool.parameters_schema();
820        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
821        assert!(actions.contains(&json!("get_content_strategy")));
822    }
823}