1use anyhow::Context;
10use async_trait::async_trait;
11use parking_lot::RwLock;
12use reqwest::Client;
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use std::collections::HashMap;
16use std::fmt::Write;
17use std::sync::Arc;
18use zeroclaw_api::tool::{Tool, ToolResult};
19use zeroclaw_config::policy::SecurityPolicy;
20use zeroclaw_config::policy::ToolOperation;
21
22const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3";
23const COMPOSIO_TOOL_VERSION_LATEST: &str = "latest";
24
25fn ensure_https(url: &str) -> anyhow::Result<()> {
26 if !url.starts_with("https://") {
27 anyhow::bail!(
28 "Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"
29 );
30 }
31 Ok(())
32}
33
34pub struct ComposioTool {
36 api_key: String,
37 default_entity_id: String,
38 security: Arc<SecurityPolicy>,
39 recent_connected_accounts: RwLock<HashMap<String, String>>,
40 action_slug_cache: RwLock<HashMap<String, String>>,
41}
42
43impl ComposioTool {
44 pub fn new(
45 api_key: &str,
46 default_entity_id: Option<&str>,
47 security: Arc<SecurityPolicy>,
48 ) -> Self {
49 Self {
50 api_key: api_key.to_string(),
51 default_entity_id: normalize_entity_id(default_entity_id.unwrap_or("default")),
52 security,
53 recent_connected_accounts: RwLock::new(HashMap::new()),
54 action_slug_cache: RwLock::new(HashMap::new()),
55 }
56 }
57
58 fn client(&self) -> Client {
59 zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts("tool.composio", 60, 10)
60 }
61
62 pub async fn list_actions(
66 &self,
67 app_name: Option<&str>,
68 ) -> anyhow::Result<Vec<ComposioAction>> {
69 self.list_actions_v3(app_name).await
70 }
71
72 async fn list_actions_v3(&self, app_name: Option<&str>) -> anyhow::Result<Vec<ComposioAction>> {
73 let url = format!("{COMPOSIO_API_BASE_V3}/tools");
74 let req = self
75 .client()
76 .get(&url)
77 .header("x-api-key", &self.api_key)
78 .query(&Self::build_list_actions_v3_query(app_name));
79
80 let resp = req.send().await?;
81 if !resp.status().is_success() {
82 let err = response_error(resp).await;
83 anyhow::bail!("Composio v3 API error: {err}");
84 }
85
86 let body: ComposioToolsResponse = resp
87 .json()
88 .await
89 .context("Failed to decode Composio v3 tools response")?;
90 self.update_action_slug_cache_from_v3_items(&body.items);
91 Ok(map_v3_tools_to_actions(body.items))
92 }
93
94 fn update_action_slug_cache_from_v3_items(&self, items: &[ComposioV3Tool]) {
95 for item in items {
96 let Some(slug) = item.slug.as_deref().or(item.name.as_deref()) else {
97 continue;
98 };
99 self.cache_action_slug(slug, slug);
100 if let Some(name) = item.name.as_deref() {
101 self.cache_action_slug(name, slug);
102 }
103 }
104 }
105
106 async fn list_connected_accounts(
108 &self,
109 app_name: Option<&str>,
110 entity_id: Option<&str>,
111 ) -> anyhow::Result<Vec<ComposioConnectedAccount>> {
112 let url = format!("{COMPOSIO_API_BASE_V3}/connected_accounts");
113 let mut req = self.client().get(&url).header("x-api-key", &self.api_key);
114
115 req = req.query(&[
116 ("limit", "50"),
117 ("order_by", "updated_at"),
118 ("order_direction", "desc"),
119 ("statuses", "INITIALIZING"),
120 ("statuses", "ACTIVE"),
121 ("statuses", "INITIATED"),
122 ]);
123
124 if let Some(app) = app_name
125 .map(normalize_app_slug)
126 .filter(|app| !app.is_empty())
127 {
128 req = req.query(&[("toolkit_slugs", app.as_str())]);
129 }
130
131 if let Some(entity) = entity_id {
132 req = req.query(&[("user_ids", entity)]);
133 }
134
135 let resp = req.send().await?;
136 if !resp.status().is_success() {
137 let err = response_error(resp).await;
138 anyhow::bail!("Composio v3 connected accounts lookup failed: {err}");
139 }
140
141 let body: ComposioConnectedAccountsResponse = resp
142 .json()
143 .await
144 .context("Failed to decode Composio v3 connected accounts response")?;
145 Ok(body.items)
146 }
147
148 fn cache_connected_account(&self, app_name: &str, entity_id: &str, connected_account_id: &str) {
149 let key = connected_account_cache_key(app_name, entity_id);
150 self.recent_connected_accounts
151 .write()
152 .insert(key, connected_account_id.to_string());
153 }
154
155 fn get_cached_connected_account(&self, app_name: &str, entity_id: &str) -> Option<String> {
156 let key = connected_account_cache_key(app_name, entity_id);
157 self.recent_connected_accounts.read().get(&key).cloned()
158 }
159
160 async fn resolve_connected_account_ref(
161 &self,
162 app_name: Option<&str>,
163 entity_id: Option<&str>,
164 ) -> anyhow::Result<Option<String>> {
165 let app = app_name
166 .map(normalize_app_slug)
167 .filter(|app| !app.is_empty());
168 let entity = entity_id.map(normalize_entity_id);
169 let (Some(app), Some(entity)) = (app, entity) else {
170 return Ok(None);
171 };
172
173 if let Some(cached) = self.get_cached_connected_account(&app, &entity) {
174 return Ok(Some(cached));
175 }
176
177 let accounts = self
178 .list_connected_accounts(Some(&app), Some(&entity))
179 .await?;
180 let Some(first) = accounts.into_iter().find(|acct| acct.is_usable()) else {
186 return Ok(None);
187 };
188
189 self.cache_connected_account(&app, &entity, &first.id);
190 Ok(Some(first.id))
191 }
192
193 pub async fn execute_action(
197 &self,
198 action_name: &str,
199 app_name_hint: Option<&str>,
200 params: serde_json::Value,
201 text: Option<&str>,
202 entity_id: Option<&str>,
203 connected_account_ref: Option<&str>,
204 ) -> anyhow::Result<serde_json::Value> {
205 let app_hint = app_name_hint
206 .map(normalize_app_slug)
207 .filter(|app| !app.is_empty())
208 .or_else(|| infer_app_slug_from_action_name(action_name));
209 let normalized_entity_id = entity_id.map(normalize_entity_id);
210 let explicit_account_ref = connected_account_ref.and_then(|candidate| {
211 let trimmed = candidate.trim();
212 (!trimmed.is_empty()).then_some(trimmed.to_string())
213 });
214 let resolved_account_ref = if explicit_account_ref.is_some() {
215 explicit_account_ref
216 } else {
217 self.resolve_connected_account_ref(app_hint.as_deref(), normalized_entity_id.as_deref())
218 .await?
219 };
220
221 let mut slug_candidates = self.build_v3_slug_candidates(action_name);
222 let mut prime_error = None;
223 if slug_candidates.is_empty()
224 && let Some(app) = app_hint.as_deref()
225 {
226 match self.list_actions(Some(app)).await {
227 Ok(_) => {
228 slug_candidates = self.build_v3_slug_candidates(action_name);
229 }
230 Err(err) => {
231 prime_error = Some(format!(
232 "Failed to refresh action list for app '{app}': {err}"
233 ));
234 }
235 }
236 }
237
238 if slug_candidates.is_empty() {
239 anyhow::bail!(
240 "Unable to determine tool slug for '{action_name}'. Run action='list' with the relevant app first to prime the cache.{}",
241 prime_error
242 .as_deref()
243 .map(|msg| format!(" ({msg})"))
244 .unwrap_or_default()
245 );
246 }
247
248 let mut v3_errors = Vec::new();
249 for slug in slug_candidates {
250 self.cache_action_slug(action_name, &slug);
251 match self
252 .execute_action_v3(
253 &slug,
254 params.clone(),
255 text,
256 normalized_entity_id.as_deref(),
257 resolved_account_ref.as_deref(),
258 )
259 .await
260 {
261 Ok(result) => return Ok(result),
262 Err(err) => v3_errors.push(format!("{slug}: {err}")),
263 }
264 }
265
266 let v3_error_summary = if v3_errors.is_empty() {
267 "no v3 candidates attempted".to_string()
268 } else {
269 v3_errors.join(" | ")
270 };
271
272 let prime_suffix = prime_error
273 .as_deref()
274 .map(|msg| format!(" ({msg})"))
275 .unwrap_or_default();
276
277 if text.is_some() {
278 anyhow::bail!(
279 "Composio v3 NLP execute failed on candidates ({v3_error_summary}){prime_suffix}{}",
280 build_connected_account_hint(
281 app_hint.as_deref(),
282 normalized_entity_id.as_deref(),
283 resolved_account_ref.as_deref(),
284 )
285 );
286 }
287
288 anyhow::bail!(
289 "Composio execute failed on v3 ({v3_error_summary}){prime_suffix}{}",
290 build_connected_account_hint(
291 app_hint.as_deref(),
292 normalized_entity_id.as_deref(),
293 resolved_account_ref.as_deref(),
294 )
295 );
296 }
297
298 fn build_v3_slug_candidates(&self, action_name: &str) -> Vec<String> {
299 let mut candidates = Vec::new();
300 let mut push_candidate = |candidate: String| {
301 if !candidate.is_empty() && !candidates.contains(&candidate) {
302 candidates.push(candidate);
303 }
304 };
305
306 if let Some(hit) = self.lookup_cached_action_slug(action_name) {
307 push_candidate(hit);
308 }
309
310 for slug in build_tool_slug_candidates(action_name) {
311 push_candidate(slug);
312 }
313
314 candidates
315 }
316
317 fn cache_action_slug(&self, alias: &str, slug: &str) {
318 let Some(key) = normalize_action_cache_key(alias) else {
319 return;
320 };
321 let trimmed_slug = slug.trim();
322 if trimmed_slug.is_empty() {
323 return;
324 }
325 self.action_slug_cache
326 .write()
327 .insert(key, trimmed_slug.to_string());
328 }
329
330 fn lookup_cached_action_slug(&self, action_name: &str) -> Option<String> {
331 let key = normalize_action_cache_key(action_name)?;
332 self.action_slug_cache.read().get(&key).cloned()
333 }
334
335 fn build_list_actions_v3_query(app_name: Option<&str>) -> Vec<(String, String)> {
336 let mut query = vec![
337 ("limit".to_string(), "200".to_string()),
338 (
339 "toolkit_versions".to_string(),
340 COMPOSIO_TOOL_VERSION_LATEST.to_string(),
341 ),
342 ];
343
344 if let Some(app) = app_name.map(str::trim).filter(|app| !app.is_empty()) {
345 query.push(("toolkits".to_string(), app.to_string()));
346 query.push(("toolkit_slug".to_string(), app.to_string()));
347 }
348
349 query
350 }
351
352 fn build_execute_action_v3_request(
353 tool_slug: &str,
354 params: serde_json::Value,
355 text: Option<&str>,
356 entity_id: Option<&str>,
357 connected_account_ref: Option<&str>,
358 ) -> (String, serde_json::Value) {
359 let url = format!("{COMPOSIO_API_BASE_V3}/tools/execute/{tool_slug}");
360 let account_ref = connected_account_ref.and_then(|candidate| {
361 let trimmed_candidate = candidate.trim();
362 (!trimmed_candidate.is_empty()).then_some(trimmed_candidate)
363 });
364
365 let mut body = json!({
366 "version": COMPOSIO_TOOL_VERSION_LATEST,
367 });
368
369 if let Some(nl_text) = text {
375 body["text"] = json!(nl_text);
376 } else {
377 body["arguments"] = params;
378 }
379
380 if let Some(entity) = entity_id {
381 body["user_id"] = json!(entity);
382 }
383 if let Some(account_ref) = account_ref {
384 body["connected_account_id"] = json!(account_ref);
385 }
386
387 (url, body)
388 }
389
390 async fn execute_action_v3(
391 &self,
392 tool_slug: &str,
393 params: serde_json::Value,
394 text: Option<&str>,
395 entity_id: Option<&str>,
396 connected_account_ref: Option<&str>,
397 ) -> anyhow::Result<serde_json::Value> {
398 let (url, body) = Self::build_execute_action_v3_request(
399 tool_slug,
400 params,
401 text,
402 entity_id,
403 connected_account_ref,
404 );
405
406 ensure_https(&url)?;
407
408 let resp = self
409 .client()
410 .post(&url)
411 .header("x-api-key", &self.api_key)
412 .json(&body)
413 .send()
414 .await?;
415
416 if !resp.status().is_success() {
417 let err = response_error(resp).await;
418 anyhow::bail!("Composio v3 action execution failed: {err}");
419 }
420
421 let result: serde_json::Value = resp
422 .json()
423 .await
424 .context("Failed to decode Composio v3 execute response")?;
425 Ok(result)
426 }
427
428 pub async fn get_connection_url(
432 &self,
433 app_name: Option<&str>,
434 auth_config_id: Option<&str>,
435 entity_id: &str,
436 ) -> anyhow::Result<ComposioConnectionLink> {
437 self.get_connection_url_v3(app_name, auth_config_id, entity_id)
438 .await
439 }
440
441 async fn get_connection_url_v3(
442 &self,
443 app_name: Option<&str>,
444 auth_config_id: Option<&str>,
445 entity_id: &str,
446 ) -> anyhow::Result<ComposioConnectionLink> {
447 let auth_config_id = match auth_config_id {
448 Some(id) => id.to_string(),
449 None => {
450 let app = app_name.ok_or_else(|| {
451 ::zeroclaw_log::record!(
452 WARN,
453 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
454 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
455 .with_attrs(::serde_json::json!({"missing": "app_or_auth_config_id"})),
456 "composio: v3 connect missing app or auth_config_id"
457 );
458 anyhow::Error::msg("Missing 'app' or 'auth_config_id' for v3 connect")
459 })?;
460 self.resolve_auth_config_id(app).await?
461 }
462 };
463
464 let url = format!("{COMPOSIO_API_BASE_V3}/connected_accounts/link");
465 let body = json!({
466 "auth_config_id": auth_config_id,
467 "user_id": entity_id,
468 });
469
470 let resp = self
471 .client()
472 .post(&url)
473 .header("x-api-key", &self.api_key)
474 .json(&body)
475 .send()
476 .await?;
477
478 if !resp.status().is_success() {
479 let err = response_error(resp).await;
480 anyhow::bail!("Composio v3 connect failed: {err}");
481 }
482
483 let result: serde_json::Value = resp
484 .json()
485 .await
486 .context("Failed to decode Composio v3 connect response")?;
487 let redirect_url = extract_redirect_url(&result).ok_or_else(|| {
488 ::zeroclaw_log::record!(
489 ERROR,
490 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
491 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
492 "composio: v3 response missing redirect URL"
493 );
494 anyhow::Error::msg("No redirect URL in Composio v3 response")
495 })?;
496 Ok(ComposioConnectionLink {
497 redirect_url,
498 connected_account_id: extract_connected_account_id(&result),
499 })
500 }
501
502 async fn get_tool_schema(&self, tool_slug: &str) -> anyhow::Result<serde_json::Value> {
507 let slug = normalize_tool_slug(tool_slug);
508 let url = format!("{COMPOSIO_API_BASE_V3}/tools/{slug}");
509 ensure_https(&url)?;
510
511 let resp = self
512 .client()
513 .get(&url)
514 .header("x-api-key", &self.api_key)
515 .query(&[("version", COMPOSIO_TOOL_VERSION_LATEST)])
516 .send()
517 .await?;
518
519 if !resp.status().is_success() {
520 let err = response_error(resp).await;
521 anyhow::bail!("Composio v3 tool schema lookup failed for '{slug}': {err}");
522 }
523
524 let body: serde_json::Value = resp
525 .json()
526 .await
527 .context("Failed to decode Composio v3 tool schema response")?;
528 Ok(body)
529 }
530
531 async fn resolve_auth_config_id(&self, app_name: &str) -> anyhow::Result<String> {
532 let url = format!("{COMPOSIO_API_BASE_V3}/auth_configs");
533
534 let resp = self
535 .client()
536 .get(&url)
537 .header("x-api-key", &self.api_key)
538 .query(&[
539 ("toolkit_slug", app_name),
540 ("show_disabled", "true"),
541 ("limit", "25"),
542 ])
543 .send()
544 .await?;
545
546 if !resp.status().is_success() {
547 let err = response_error(resp).await;
548 anyhow::bail!("Composio v3 auth config lookup failed: {err}");
549 }
550
551 let body: ComposioAuthConfigsResponse = resp
552 .json()
553 .await
554 .context("Failed to decode Composio v3 auth configs response")?;
555
556 if body.items.is_empty() {
557 anyhow::bail!(
558 "No auth config found for toolkit '{app_name}'. Create one in Composio first."
559 );
560 }
561
562 let preferred = body
563 .items
564 .iter()
565 .find(|cfg| cfg.is_enabled())
566 .or_else(|| body.items.first())
567 .context("No usable auth config returned by Composio")?;
568
569 Ok(preferred.id.clone())
570 }
571}
572
573#[async_trait]
574impl Tool for ComposioTool {
575 fn name(&self) -> &str {
576 "composio"
577 }
578
579 fn description(&self) -> &str {
580 "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \
581 Use action='list' to see available actions (includes parameter names). \
582 action='execute' with action_name/tool_slug and params to run an action. \
583 If you are unsure of the exact params, pass 'text' instead with a natural-language description \
584 of what you want (Composio will resolve the correct parameters via NLP). \
585 action='list_accounts' or action='connected_accounts' to list OAuth-connected accounts. \
586 action='connect' with app/auth_config_id to get OAuth URL. \
587 connected_account_id is auto-resolved when omitted."
588 }
589
590 fn parameters_schema(&self) -> serde_json::Value {
591 json!({
592 "type": "object",
593 "properties": {
594 "action": {
595 "type": "string",
596 "description": "The operation: 'list' (list available actions), 'list_accounts'/'connected_accounts' (list connected accounts), 'execute' (run an action), or 'connect' (get OAuth URL)",
597 "enum": ["list", "list_accounts", "connected_accounts", "execute", "connect"]
598 },
599 "app": {
600 "type": "string",
601 "description": "Toolkit slug filter for 'list' or 'list_accounts', optional app hint for 'execute', or toolkit/app for 'connect' (e.g. 'gmail', 'notion', 'github')"
602 },
603 "action_name": {
604 "type": "string",
605 "description": "Action/tool identifier to execute (legacy aliases supported)"
606 },
607 "tool_slug": {
608 "type": "string",
609 "description": "Preferred v3 tool slug to execute (alias of action_name)"
610 },
611 "params": {
612 "type": "object",
613 "description": "Structured parameters to pass to the action (use the key names shown by action='list')"
614 },
615 "text": {
616 "type": "string",
617 "description": "Natural-language description of what you want the action to do (alternative to 'params' when you are unsure of the exact parameter names). Composio will resolve the correct parameters via NLP. Mutually exclusive with 'params'."
618 },
619 "entity_id": {
620 "type": "string",
621 "description": "Entity/user ID for multi-user setups (defaults to composio.entity_id from config)"
622 },
623 "auth_config_id": {
624 "type": "string",
625 "description": "Optional Composio v3 auth config id for connect flow"
626 },
627 "connected_account_id": {
628 "type": "string",
629 "description": "Optional connected account ID for execute flow when a specific account is required"
630 }
631 },
632 "required": ["action"]
633 })
634 }
635
636 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
637 let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
638 ::zeroclaw_log::record!(
639 WARN,
640 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
641 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
642 .with_attrs(::serde_json::json!({"param": "action"})),
643 "composio: missing action parameter"
644 );
645 anyhow::Error::msg("Missing 'action' parameter")
646 })?;
647
648 let entity_id = args
649 .get("entity_id")
650 .and_then(|v| v.as_str())
651 .unwrap_or(self.default_entity_id.as_str());
652
653 match action {
654 "list" => {
655 let app = args.get("app").and_then(|v| v.as_str());
656 match self.list_actions(app).await {
657 Ok(actions) => {
658 let summary: Vec<String> = actions
659 .iter()
660 .take(20)
661 .map(|a| {
662 let params_hint =
663 format_input_params_hint(a.input_parameters.as_ref());
664 format!(
665 "- {} ({}): {}{}",
666 a.name,
667 a.app_name.as_deref().unwrap_or("?"),
668 a.description.as_deref().unwrap_or(""),
669 params_hint,
670 )
671 })
672 .collect();
673 let total = actions.len();
674 let output = format!(
675 "Found {total} available actions:\n{}{}",
676 summary.join("\n"),
677 if total > 20 {
678 format!("\n... and {} more", total - 20)
679 } else {
680 String::new()
681 }
682 );
683 Ok(ToolResult {
684 success: true,
685 output,
686 error: None,
687 })
688 }
689 Err(e) => Ok(ToolResult {
690 success: false,
691 output: String::new(),
692 error: Some(format!("Failed to list actions: {e}")),
693 }),
694 }
695 }
696
697 "list_accounts" | "connected_accounts" => {
699 let app = args.get("app").and_then(|v| v.as_str());
700 match self.list_connected_accounts(app, Some(entity_id)).await {
701 Ok(accounts) => {
702 if accounts.is_empty() {
703 let app_hint = app
704 .map(|value| format!(" for app '{value}'"))
705 .unwrap_or_default();
706 return Ok(ToolResult {
707 success: true,
708 output: format!(
709 "No connected accounts found{app_hint} for entity '{entity_id}'. Run action='connect' first."
710 ),
711 error: None,
712 });
713 }
714
715 let summary: Vec<String> = accounts
716 .iter()
717 .take(20)
718 .map(|account| {
719 let toolkit = account.toolkit_slug().unwrap_or("?");
720 format!("- {} [{}] toolkit={toolkit}", account.id, account.status)
721 })
722 .collect();
723 let total = accounts.len();
724 let output = format!(
725 "Found {total} connected accounts (entity '{entity_id}'):\n{}{}\nUse connected_account_id in action='execute' when needed.",
726 summary.join("\n"),
727 if total > 20 {
728 format!("\n... and {} more", total - 20)
729 } else {
730 String::new()
731 }
732 );
733 Ok(ToolResult {
734 success: true,
735 output,
736 error: None,
737 })
738 }
739 Err(e) => Ok(ToolResult {
740 success: false,
741 output: String::new(),
742 error: Some(format!("Failed to list connected accounts: {e}")),
743 }),
744 }
745 }
746
747 "execute" => {
748 if let Err(error) = self
749 .security
750 .enforce_tool_operation(ToolOperation::Act, "composio.execute")
751 {
752 return Ok(ToolResult {
753 success: false,
754 output: String::new(),
755 error: Some(error),
756 });
757 }
758
759 let action_name = args
760 .get("tool_slug")
761 .or_else(|| args.get("action_name"))
762 .and_then(|v| v.as_str())
763 .ok_or_else(|| {
764 ::zeroclaw_log::record!(
765 WARN,
766 ::zeroclaw_log::Event::new(
767 module_path!(),
768 ::zeroclaw_log::Action::Reject
769 )
770 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
771 .with_attrs(
772 ::serde_json::json!({"missing": "action_name_or_tool_slug"})
773 ),
774 "composio: execute missing action_name/tool_slug"
775 );
776 anyhow::Error::msg("Missing 'action_name' (or 'tool_slug') for execute")
777 })?;
778
779 let app = args.get("app").and_then(|v| v.as_str());
780 let params = args.get("params").cloned().unwrap_or(json!({}));
781 let text = args.get("text").and_then(|v| v.as_str());
782 let acct_ref = args.get("connected_account_id").and_then(|v| v.as_str());
783
784 match self
785 .execute_action(action_name, app, params, text, Some(entity_id), acct_ref)
786 .await
787 {
788 Ok(result) => {
789 let output = serde_json::to_string_pretty(&result)
790 .unwrap_or_else(|_| format!("{result:?}"));
791 Ok(ToolResult {
792 success: true,
793 output,
794 error: None,
795 })
796 }
797 Err(e) => {
798 let schema_hint = self
801 .get_tool_schema(action_name)
802 .await
803 .ok()
804 .and_then(|s| format_schema_hint(&s))
805 .unwrap_or_default();
806 Ok(ToolResult {
807 success: false,
808 output: String::new(),
809 error: Some(format!("Action execution failed: {e}{schema_hint}")),
810 })
811 }
812 }
813 }
814
815 "connect" => {
816 if let Err(error) = self
817 .security
818 .enforce_tool_operation(ToolOperation::Act, "composio.connect")
819 {
820 return Ok(ToolResult {
821 success: false,
822 output: String::new(),
823 error: Some(error),
824 });
825 }
826
827 let app = args.get("app").and_then(|v| v.as_str());
828 let auth_config_id = args.get("auth_config_id").and_then(|v| v.as_str());
829
830 if app.is_none() && auth_config_id.is_none() {
831 anyhow::bail!("Missing 'app' or 'auth_config_id' for connect");
832 }
833
834 match self
835 .get_connection_url(app, auth_config_id, entity_id)
836 .await
837 {
838 Ok(link) => {
839 let target =
840 app.unwrap_or(auth_config_id.unwrap_or("provided auth config"));
841 let mut output =
842 format!("Open this URL to connect {target}:\n{}", link.redirect_url);
843 if let Some(connected_account_id) = link.connected_account_id.as_deref() {
844 if let Some(app_name) = app {
845 self.cache_connected_account(
846 app_name,
847 entity_id,
848 connected_account_id,
849 );
850 }
851 let _ =
852 write!(output, "\nConnected account ID: {connected_account_id}");
853 }
854 Ok(ToolResult {
855 success: true,
856 output,
857 error: None,
858 })
859 }
860 Err(e) => Ok(ToolResult {
861 success: false,
862 output: String::new(),
863 error: Some(format!("Failed to get connection URL: {e}")),
864 }),
865 }
866 }
867
868 _ => Ok(ToolResult {
869 success: false,
870 output: String::new(),
871 error: Some(format!(
872 "Unknown action '{action}'. Use 'list', 'list_accounts', 'execute', or 'connect'."
873 )),
874 }),
875 }
876 }
877}
878
879fn normalize_entity_id(entity_id: &str) -> String {
880 let trimmed = entity_id.trim();
881 if trimmed.is_empty() {
882 "default".to_string()
883 } else {
884 trimmed.to_string()
885 }
886}
887
888fn normalize_tool_slug(action_name: &str) -> String {
889 action_name.trim().replace('_', "-").to_ascii_lowercase()
890}
891
892fn build_tool_slug_candidates(action_name: &str) -> Vec<String> {
893 let trimmed = action_name.trim();
894 if trimmed.is_empty() {
895 return Vec::new();
896 }
897
898 let mut candidates = Vec::new();
899 let mut push_candidate = |candidate: String| {
900 if !candidate.is_empty() && !candidates.contains(&candidate) {
901 candidates.push(candidate);
902 }
903 };
904
905 push_candidate(trimmed.to_string());
908 push_candidate(normalize_tool_slug(trimmed));
909
910 let lower = trimmed.to_ascii_lowercase();
911 push_candidate(lower.clone());
912
913 let underscore_lower = lower.replace('-', "_");
914 push_candidate(underscore_lower);
915
916 let hyphen_lower = lower.replace('_', "-");
917 push_candidate(hyphen_lower);
918
919 let upper = trimmed.to_ascii_uppercase();
920 push_candidate(upper.clone());
921 push_candidate(upper.replace('-', "_"));
922 push_candidate(upper.replace('_', "-"));
923
924 candidates
925}
926
927fn normalize_app_slug(app_name: &str) -> String {
928 app_name
929 .trim()
930 .replace('_', "-")
931 .to_ascii_lowercase()
932 .split('-')
933 .filter(|part| !part.is_empty())
934 .collect::<Vec<_>>()
935 .join("-")
936}
937
938fn infer_app_slug_from_action_name(action_name: &str) -> Option<String> {
939 let trimmed = action_name.trim();
940 if trimmed.is_empty() {
941 return None;
942 }
943
944 let raw = if trimmed.contains('-') {
945 trimmed.split('-').next()
946 } else if trimmed.contains('_') {
947 trimmed.split('_').next()
948 } else {
949 None
950 }?;
951
952 let app = normalize_app_slug(raw);
953 (!app.is_empty()).then_some(app)
954}
955
956fn connected_account_cache_key(app_name: &str, entity_id: &str) -> String {
957 format!(
958 "{}:{}",
959 normalize_entity_id(entity_id),
960 normalize_app_slug(app_name)
961 )
962}
963
964fn normalize_action_cache_key(alias: &str) -> Option<String> {
965 let trimmed = alias.trim();
966 if trimmed.is_empty() {
967 return None;
968 }
969
970 Some(
971 trimmed
972 .to_ascii_lowercase()
973 .replace('_', "-")
974 .split('-')
975 .filter(|part| !part.is_empty())
976 .collect::<Vec<_>>()
977 .join("-"),
978 )
979}
980
981fn build_connected_account_hint(
982 app_hint: Option<&str>,
983 entity_id: Option<&str>,
984 connected_account_ref: Option<&str>,
985) -> String {
986 if connected_account_ref.is_some() {
987 return String::new();
988 }
989
990 let Some(entity) = entity_id else {
991 return String::new();
992 };
993
994 if let Some(app) = app_hint {
995 format!(
996 " Hint: use action='list_accounts' with app='{app}' and entity_id='{entity}' to retrieve connected_account_id."
997 )
998 } else {
999 format!(
1000 " Hint: use action='list_accounts' with entity_id='{entity}' to retrieve connected_account_id."
1001 )
1002 }
1003}
1004
1005fn map_v3_tools_to_actions(items: Vec<ComposioV3Tool>) -> Vec<ComposioAction> {
1006 items
1007 .into_iter()
1008 .filter_map(|item| {
1009 let name = item.slug.or(item.name.clone())?;
1010 let app_name = item
1011 .toolkit
1012 .as_ref()
1013 .and_then(|toolkit| toolkit.slug.clone().or(toolkit.name.clone()))
1014 .or(item.app_name);
1015 let description = item.description.or(item.name);
1016 Some(ComposioAction {
1017 name,
1018 app_name,
1019 description,
1020 enabled: true,
1021 input_parameters: item.input_parameters,
1022 })
1023 })
1024 .collect()
1025}
1026
1027fn extract_redirect_url(result: &serde_json::Value) -> Option<String> {
1028 result
1029 .get("redirect_url")
1030 .and_then(|v| v.as_str())
1031 .or_else(|| result.get("redirectUrl").and_then(|v| v.as_str()))
1032 .or_else(|| {
1033 result
1034 .get("data")
1035 .and_then(|v| v.get("redirect_url"))
1036 .and_then(|v| v.as_str())
1037 })
1038 .map(ToString::to_string)
1039}
1040
1041fn extract_connected_account_id(result: &serde_json::Value) -> Option<String> {
1042 result
1043 .get("connected_account_id")
1044 .and_then(|v| v.as_str())
1045 .or_else(|| result.get("connectedAccountId").and_then(|v| v.as_str()))
1046 .or_else(|| {
1047 result
1048 .get("data")
1049 .and_then(|v| v.get("connected_account_id"))
1050 .and_then(|v| v.as_str())
1051 })
1052 .or_else(|| {
1053 result
1054 .get("data")
1055 .and_then(|v| v.get("connectedAccountId"))
1056 .and_then(|v| v.as_str())
1057 })
1058 .map(ToString::to_string)
1059}
1060
1061async fn response_error(resp: reqwest::Response) -> String {
1062 let status = resp.status();
1063 let body = resp.text().await.unwrap_or_default();
1064 if body.trim().is_empty() {
1065 return format!("HTTP {}", status.as_u16());
1066 }
1067
1068 if let Some(api_error) = extract_api_error_message(&body) {
1069 return format!(
1070 "HTTP {}: {}",
1071 status.as_u16(),
1072 sanitize_error_message(&api_error)
1073 );
1074 }
1075
1076 format!("HTTP {}", status.as_u16())
1077}
1078
1079fn sanitize_error_message(message: &str) -> String {
1080 let mut sanitized = message.replace('\n', " ");
1081 for marker in [
1082 "connected_account_id",
1083 "connectedAccountId",
1084 "entity_id",
1085 "entityId",
1086 "user_id",
1087 "userId",
1088 ] {
1089 sanitized = sanitized.replace(marker, "[redacted]");
1090 }
1091
1092 let max_chars = 240;
1093 if sanitized.chars().count() <= max_chars {
1094 sanitized
1095 } else {
1096 let mut end = max_chars;
1097 while end > 0 && !sanitized.is_char_boundary(end) {
1098 end -= 1;
1099 }
1100 format!("{}...", &sanitized[..end])
1101 }
1102}
1103
1104fn extract_api_error_message(body: &str) -> Option<String> {
1105 let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
1106 parsed
1107 .get("error")
1108 .and_then(|v| v.get("message"))
1109 .and_then(|v| v.as_str())
1110 .map(ToString::to_string)
1111 .or_else(|| {
1112 parsed
1113 .get("message")
1114 .and_then(|v| v.as_str())
1115 .map(ToString::to_string)
1116 })
1117}
1118
1119fn format_input_params_hint(schema: Option<&serde_json::Value>) -> String {
1124 let props = schema
1125 .and_then(|v| v.get("properties"))
1126 .and_then(|v| v.as_object());
1127 let required: Vec<&str> = schema
1128 .and_then(|v| v.get("required"))
1129 .and_then(|v| v.as_array())
1130 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1131 .unwrap_or_default();
1132
1133 let Some(props) = props else {
1134 return String::new();
1135 };
1136 if props.is_empty() {
1137 return String::new();
1138 }
1139
1140 let keys: Vec<String> = props
1141 .keys()
1142 .map(|k| {
1143 if required.contains(&k.as_str()) {
1144 format!("{k}*")
1145 } else {
1146 k.clone()
1147 }
1148 })
1149 .collect();
1150 format!(" [params: {}]", keys.join(", "))
1151}
1152
1153fn floor_char_boundary_compat(text: &str, index: usize) -> usize {
1154 let mut end = index.min(text.len());
1155 while end > 0 && !text.is_char_boundary(end) {
1156 end -= 1;
1157 }
1158 end
1159}
1160
1161fn format_schema_hint(schema: &serde_json::Value) -> Option<String> {
1166 let input_params = schema.get("input_parameters")?;
1167 let props = input_params.get("properties")?.as_object()?;
1168 if props.is_empty() {
1169 return None;
1170 }
1171
1172 let required: Vec<&str> = input_params
1173 .get("required")
1174 .and_then(|v| v.as_array())
1175 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1176 .unwrap_or_default();
1177
1178 let mut lines = Vec::new();
1179 for (key, spec) in props {
1180 let type_str = spec.get("type").and_then(|v| v.as_str()).unwrap_or("any");
1181 let desc = spec
1182 .get("description")
1183 .and_then(|v| v.as_str())
1184 .unwrap_or("");
1185 let req = if required.contains(&key.as_str()) {
1186 " (required)"
1187 } else {
1188 ""
1189 };
1190 let desc_suffix = if desc.is_empty() {
1191 String::new()
1192 } else {
1193 let short = if desc.len() > 80 {
1196 let end = floor_char_boundary_compat(desc, 77);
1197 format!("{}...", &desc[..end])
1198 } else {
1199 desc.to_string()
1200 };
1201 format!(" - {short}")
1202 };
1203 lines.push(format!(" {key}: {type_str}{req}{desc_suffix}"));
1204 }
1205
1206 Some(format!(
1207 "\n\nExpected input parameters:\n{}",
1208 lines.join("\n")
1209 ))
1210}
1211
1212#[derive(Debug, Deserialize)]
1215struct ComposioToolsResponse {
1216 #[serde(default)]
1217 items: Vec<ComposioV3Tool>,
1218}
1219
1220#[derive(Debug, Deserialize)]
1221struct ComposioConnectedAccountsResponse {
1222 #[serde(default)]
1223 items: Vec<ComposioConnectedAccount>,
1224}
1225
1226#[derive(Debug, Clone, Deserialize)]
1227struct ComposioConnectedAccount {
1228 id: String,
1229 #[serde(default)]
1230 status: String,
1231 #[serde(default)]
1232 toolkit: Option<ComposioToolkitRef>,
1233}
1234
1235impl ComposioConnectedAccount {
1236 fn is_usable(&self) -> bool {
1237 self.status.eq_ignore_ascii_case("INITIALIZING")
1238 || self.status.eq_ignore_ascii_case("ACTIVE")
1239 || self.status.eq_ignore_ascii_case("INITIATED")
1240 }
1241
1242 fn toolkit_slug(&self) -> Option<&str> {
1243 self.toolkit
1244 .as_ref()
1245 .and_then(|toolkit| toolkit.slug.as_deref())
1246 }
1247}
1248
1249#[derive(Debug, Clone, Deserialize)]
1250struct ComposioV3Tool {
1251 #[serde(default)]
1252 slug: Option<String>,
1253 #[serde(default)]
1254 name: Option<String>,
1255 #[serde(default)]
1256 description: Option<String>,
1257 #[serde(rename = "appName", default)]
1258 app_name: Option<String>,
1259 #[serde(default)]
1260 toolkit: Option<ComposioToolkitRef>,
1261 #[serde(default)]
1263 input_parameters: Option<serde_json::Value>,
1264}
1265
1266#[derive(Debug, Clone, Deserialize)]
1267struct ComposioToolkitRef {
1268 #[serde(default)]
1269 slug: Option<String>,
1270 #[serde(default)]
1271 name: Option<String>,
1272}
1273
1274#[derive(Debug, Deserialize)]
1275struct ComposioAuthConfigsResponse {
1276 #[serde(default)]
1277 items: Vec<ComposioAuthConfig>,
1278}
1279
1280#[derive(Debug, Clone)]
1281pub struct ComposioConnectionLink {
1282 pub redirect_url: String,
1283 pub connected_account_id: Option<String>,
1284}
1285
1286#[derive(Debug, Clone, Deserialize)]
1287struct ComposioAuthConfig {
1288 id: String,
1289 #[serde(default)]
1290 status: Option<String>,
1291 #[serde(default)]
1292 enabled: Option<bool>,
1293}
1294
1295impl ComposioAuthConfig {
1296 fn is_enabled(&self) -> bool {
1297 self.enabled.unwrap_or(false)
1298 || self
1299 .status
1300 .as_deref()
1301 .is_some_and(|v| v.eq_ignore_ascii_case("enabled"))
1302 }
1303}
1304
1305#[derive(Debug, Clone, Serialize, Deserialize)]
1306pub struct ComposioAction {
1307 pub name: String,
1308 #[serde(rename = "appName")]
1309 pub app_name: Option<String>,
1310 pub description: Option<String>,
1311 #[serde(default)]
1312 pub enabled: bool,
1313 #[serde(default, skip_serializing_if = "Option::is_none")]
1315 pub input_parameters: Option<serde_json::Value>,
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321 use zeroclaw_config::autonomy::AutonomyLevel;
1322 use zeroclaw_config::policy::SecurityPolicy;
1323
1324 fn test_security() -> Arc<SecurityPolicy> {
1325 Arc::new(SecurityPolicy::default())
1326 }
1327
1328 #[test]
1331 fn composio_tool_has_correct_name() {
1332 let tool = ComposioTool::new("test-key", None, test_security());
1333 assert_eq!(tool.name(), "composio");
1334 }
1335
1336 #[test]
1337 fn composio_tool_has_description() {
1338 let _tool = ComposioTool::new("test-key", None, test_security());
1339 assert!(
1340 !ComposioTool::new("test-key", None, test_security())
1341 .description()
1342 .is_empty()
1343 );
1344 assert!(
1345 ComposioTool::new("test-key", None, test_security())
1346 .description()
1347 .contains("1000+")
1348 );
1349 }
1350
1351 #[test]
1352 fn composio_tool_schema_has_required_fields() {
1353 let tool = ComposioTool::new("test-key", None, test_security());
1354 let schema = tool.parameters_schema();
1355 assert!(schema["properties"]["action"].is_object());
1356 assert!(schema["properties"]["action_name"].is_object());
1357 assert!(schema["properties"]["tool_slug"].is_object());
1358 assert!(schema["properties"]["params"].is_object());
1359 assert!(schema["properties"]["app"].is_object());
1360 assert!(schema["properties"]["auth_config_id"].is_object());
1361 assert!(schema["properties"]["connected_account_id"].is_object());
1362 let required = schema["required"].as_array().unwrap();
1363 assert!(required.contains(&json!("action")));
1364 let enum_values = schema["properties"]["action"]["enum"]
1365 .as_array()
1366 .unwrap()
1367 .iter()
1368 .filter_map(|v| v.as_str())
1369 .collect::<Vec<_>>();
1370 assert!(enum_values.contains(&"list_accounts"));
1371 }
1372
1373 #[test]
1374 fn composio_tool_spec_roundtrip() {
1375 let tool = ComposioTool::new("test-key", None, test_security());
1376 let spec = tool.spec();
1377 assert_eq!(spec.name, "composio");
1378 assert!(spec.parameters.is_object());
1379 }
1380
1381 #[tokio::test]
1384 async fn execute_missing_action_returns_error() {
1385 let tool = ComposioTool::new("test-key", None, test_security());
1386 let result = tool.execute(json!({})).await;
1387 assert!(result.is_err());
1388 }
1389
1390 #[tokio::test]
1391 async fn execute_unknown_action_returns_error() {
1392 let tool = ComposioTool::new("test-key", None, test_security());
1393 let result = tool.execute(json!({"action": "unknown"})).await.unwrap();
1394 assert!(!result.success);
1395 assert!(result.error.as_ref().unwrap().contains("Unknown action"));
1396 }
1397
1398 #[tokio::test]
1399 async fn execute_without_action_name_returns_error() {
1400 let tool = ComposioTool::new("test-key", None, test_security());
1401 let result = tool.execute(json!({"action": "execute"})).await;
1402 assert!(result.is_err());
1403 }
1404
1405 #[tokio::test]
1406 async fn connect_without_target_returns_error() {
1407 let tool = ComposioTool::new("test-key", None, test_security());
1408 let result = tool.execute(json!({"action": "connect"})).await;
1409 assert!(result.is_err());
1410 }
1411
1412 #[tokio::test]
1413 async fn execute_blocked_in_readonly_mode() {
1414 let readonly = Arc::new(SecurityPolicy {
1415 autonomy: AutonomyLevel::ReadOnly,
1416 ..SecurityPolicy::default()
1417 });
1418 let tool = ComposioTool::new("test-key", None, readonly);
1419 let result = tool
1420 .execute(json!({
1421 "action": "execute",
1422 "action_name": "GITHUB_LIST_REPOS"
1423 }))
1424 .await
1425 .unwrap();
1426 assert!(!result.success);
1427 assert!(
1428 result
1429 .error
1430 .as_deref()
1431 .unwrap_or("")
1432 .contains("read-only mode")
1433 );
1434 }
1435
1436 #[tokio::test]
1437 async fn execute_blocked_when_rate_limited() {
1438 let limited = Arc::new(SecurityPolicy {
1439 max_actions_per_hour: 0,
1440 ..SecurityPolicy::default()
1441 });
1442 let tool = ComposioTool::new("test-key", None, limited);
1443 let result = tool
1444 .execute(json!({
1445 "action": "execute",
1446 "action_name": "GITHUB_LIST_REPOS"
1447 }))
1448 .await
1449 .unwrap();
1450 assert!(!result.success);
1451 assert!(
1452 result
1453 .error
1454 .as_deref()
1455 .unwrap_or("")
1456 .contains("Rate limit exceeded")
1457 );
1458 }
1459
1460 #[test]
1463 fn composio_action_deserializes() {
1464 let json_str = r#"{"name": "GMAIL_FETCH_EMAILS", "appName": "gmail", "description": "Fetch emails", "enabled": true}"#;
1465 let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1466 assert_eq!(action.name, "GMAIL_FETCH_EMAILS");
1467 assert_eq!(action.app_name.as_deref(), Some("gmail"));
1468 assert!(action.enabled);
1469 }
1470
1471 #[test]
1472 fn composio_tools_response_deserializes() {
1473 let json_str = r#"{"items": [{"slug": "test-action", "name": "TEST_ACTION", "appName": "test", "description": "A test"}]}"#;
1474 let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1475 assert_eq!(resp.items.len(), 1);
1476 assert_eq!(resp.items[0].slug.as_deref(), Some("test-action"));
1477 }
1478
1479 #[test]
1480 fn composio_tools_response_empty() {
1481 let json_str = r#"{"items": []}"#;
1482 let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1483 assert!(resp.items.is_empty());
1484 }
1485
1486 #[test]
1487 fn composio_tools_response_missing_items_defaults() {
1488 let json_str = r"{}";
1489 let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1490 assert!(resp.items.is_empty());
1491 }
1492
1493 #[test]
1494 fn composio_v3_tools_response_maps_to_actions() {
1495 let json_str = r#"{
1496 "items": [
1497 {
1498 "slug": "gmail-fetch-emails",
1499 "name": "Gmail Fetch Emails",
1500 "description": "Fetch inbox emails",
1501 "toolkit": { "slug": "gmail", "name": "Gmail" }
1502 }
1503 ]
1504 }"#;
1505 let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1506 let actions = map_v3_tools_to_actions(resp.items);
1507 assert_eq!(actions.len(), 1);
1508 assert_eq!(actions[0].name, "gmail-fetch-emails");
1509 assert_eq!(actions[0].app_name.as_deref(), Some("gmail"));
1510 assert_eq!(
1511 actions[0].description.as_deref(),
1512 Some("Fetch inbox emails")
1513 );
1514 }
1515
1516 #[test]
1517 fn normalize_entity_id_falls_back_to_default_when_blank() {
1518 assert_eq!(normalize_entity_id(" "), "default");
1519 assert_eq!(normalize_entity_id("workspace-user"), "workspace-user");
1520 }
1521
1522 #[test]
1523 fn normalize_tool_slug_supports_legacy_action_name() {
1524 assert_eq!(
1525 normalize_tool_slug("GMAIL_FETCH_EMAILS"),
1526 "gmail-fetch-emails"
1527 );
1528 assert_eq!(
1529 normalize_tool_slug(" github-list-repos "),
1530 "github-list-repos"
1531 );
1532 }
1533
1534 #[test]
1535 fn build_tool_slug_candidates_cover_common_variants() {
1536 let candidates = build_tool_slug_candidates("GMAIL_FETCH_EMAILS");
1537 assert_eq!(
1538 candidates.first().map(String::as_str),
1539 Some("GMAIL_FETCH_EMAILS")
1540 );
1541 assert!(candidates.contains(&"gmail-fetch-emails".to_string()));
1542 assert!(candidates.contains(&"gmail_fetch_emails".to_string()));
1543 assert!(candidates.contains(&"GMAIL_FETCH_EMAILS".to_string()));
1544
1545 let hyphen = build_tool_slug_candidates("github-list-repos");
1546 assert_eq!(
1547 hyphen.first().map(String::as_str),
1548 Some("github-list-repos")
1549 );
1550 assert!(hyphen.contains(&"github_list_repos".to_string()));
1551 }
1552
1553 #[test]
1554 fn floor_char_boundary_compat_handles_multibyte_offsets() {
1555 let text = "abc😀def";
1556 assert_eq!(floor_char_boundary_compat(text, 5), 3);
1558 assert_eq!(floor_char_boundary_compat(text, usize::MAX), text.len());
1559 }
1560
1561 #[test]
1562 fn normalize_action_cache_key_merges_underscore_and_hyphen_variants() {
1563 assert_eq!(
1564 normalize_action_cache_key(" GMAIL_FETCH_EMAILS ").as_deref(),
1565 Some("gmail-fetch-emails")
1566 );
1567 assert_eq!(
1568 normalize_action_cache_key("gmail-fetch-emails").as_deref(),
1569 Some("gmail-fetch-emails")
1570 );
1571 assert_eq!(normalize_action_cache_key(" ").as_deref(), None);
1572 }
1573
1574 #[test]
1575 fn normalize_app_slug_removes_spaces_and_normalizes_case() {
1576 assert_eq!(normalize_app_slug(" Gmail "), "gmail");
1577 assert_eq!(normalize_app_slug("GITHUB_APP"), "github-app");
1578 }
1579
1580 #[test]
1581 fn infer_app_slug_from_action_name_handles_v2_and_v3_formats() {
1582 assert_eq!(
1583 infer_app_slug_from_action_name("gmail-fetch-emails").as_deref(),
1584 Some("gmail")
1585 );
1586 assert_eq!(
1587 infer_app_slug_from_action_name("GMAIL_FETCH_EMAILS").as_deref(),
1588 Some("gmail")
1589 );
1590 assert!(infer_app_slug_from_action_name("execute").is_none());
1591 }
1592
1593 #[test]
1594 fn connected_account_cache_key_is_stable() {
1595 assert_eq!(
1596 connected_account_cache_key("GMAIL", " default "),
1597 "default:gmail"
1598 );
1599 }
1600
1601 #[test]
1602 fn build_connected_account_hint_returns_guidance_when_missing_ref() {
1603 let hint = build_connected_account_hint(Some("gmail"), Some("default"), None);
1604 assert!(hint.contains("list_accounts"));
1605 assert!(hint.contains("gmail"));
1606 assert!(hint.contains("default"));
1607 }
1608
1609 #[test]
1610 fn build_connected_account_hint_without_app_is_still_actionable() {
1611 let hint = build_connected_account_hint(None, Some("default"), None);
1612 assert!(hint.contains("list_accounts"));
1613 assert!(hint.contains("entity_id='default'"));
1614 assert!(!hint.contains("app='"));
1615 }
1616
1617 #[test]
1618 fn connected_account_is_usable_for_initializing_active_and_initiated() {
1619 for status in ["INITIALIZING", "ACTIVE", "INITIATED"] {
1620 let account = ComposioConnectedAccount {
1621 id: "ca_1".to_string(),
1622 status: status.to_string(),
1623 toolkit: None,
1624 };
1625 assert!(account.is_usable(), "status {status} should be usable");
1626 }
1627 }
1628
1629 #[test]
1630 fn extract_connected_account_id_supports_common_shapes() {
1631 let root = json!({"connected_account_id": "ca_root"});
1632 let camel = json!({"connectedAccountId": "ca_camel"});
1633 let nested = json!({"data": {"connected_account_id": "ca_nested"}});
1634
1635 assert_eq!(
1636 extract_connected_account_id(&root).as_deref(),
1637 Some("ca_root")
1638 );
1639 assert_eq!(
1640 extract_connected_account_id(&camel).as_deref(),
1641 Some("ca_camel")
1642 );
1643 assert_eq!(
1644 extract_connected_account_id(&nested).as_deref(),
1645 Some("ca_nested")
1646 );
1647 }
1648
1649 #[test]
1650 fn extract_redirect_url_supports_v2_and_v3_shapes() {
1651 let v2 = json!({"redirectUrl": "https://app.composio.dev/connect-v2"});
1652 let v3 = json!({"redirect_url": "https://app.composio.dev/connect-v3"});
1653 let nested = json!({"data": {"redirect_url": "https://app.composio.dev/connect-nested"}});
1654
1655 assert_eq!(
1656 extract_redirect_url(&v2).as_deref(),
1657 Some("https://app.composio.dev/connect-v2")
1658 );
1659 assert_eq!(
1660 extract_redirect_url(&v3).as_deref(),
1661 Some("https://app.composio.dev/connect-v3")
1662 );
1663 assert_eq!(
1664 extract_redirect_url(&nested).as_deref(),
1665 Some("https://app.composio.dev/connect-nested")
1666 );
1667 }
1668
1669 #[test]
1670 fn auth_config_prefers_enabled_status() {
1671 let enabled = ComposioAuthConfig {
1672 id: "cfg_1".into(),
1673 status: Some("ENABLED".into()),
1674 enabled: None,
1675 };
1676 let disabled = ComposioAuthConfig {
1677 id: "cfg_2".into(),
1678 status: Some("DISABLED".into()),
1679 enabled: Some(false),
1680 };
1681
1682 assert!(enabled.is_enabled());
1683 assert!(!disabled.is_enabled());
1684 }
1685
1686 #[test]
1687 fn extract_api_error_message_from_common_shapes() {
1688 let nested = r#"{"error":{"message":"tool not found"}}"#;
1689 let flat = r#"{"message":"invalid api key"}"#;
1690
1691 assert_eq!(
1692 extract_api_error_message(nested).as_deref(),
1693 Some("tool not found")
1694 );
1695 assert_eq!(
1696 extract_api_error_message(flat).as_deref(),
1697 Some("invalid api key")
1698 );
1699 assert_eq!(extract_api_error_message("not-json"), None);
1700 }
1701
1702 #[test]
1703 fn composio_action_with_null_fields() {
1704 let json_str =
1705 r#"{"name": "TEST_ACTION", "appName": null, "description": null, "enabled": false}"#;
1706 let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1707 assert_eq!(action.name, "TEST_ACTION");
1708 assert!(action.app_name.is_none());
1709 assert!(action.description.is_none());
1710 assert!(!action.enabled);
1711 }
1712
1713 #[test]
1714 fn composio_action_with_special_characters() {
1715 let json_str = r#"{"name": "GMAIL_SEND_EMAIL_WITH_ATTACHMENT", "appName": "gmail", "description": "Send email with attachment & special chars: <>'\"\"", "enabled": true}"#;
1716 let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1717 assert_eq!(action.name, "GMAIL_SEND_EMAIL_WITH_ATTACHMENT");
1718 assert!(action.description.as_ref().unwrap().contains('&'));
1719 assert!(action.description.as_ref().unwrap().contains('<'));
1720 }
1721
1722 #[test]
1723 fn composio_action_with_unicode() {
1724 let json_str = r#"{"name": "SLACK_SEND_MESSAGE", "appName": "slack", "description": "Send message with emoji 🎉 and unicode Ω", "enabled": true}"#;
1725 let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1726 assert!(action.description.as_ref().unwrap().contains("🎉"));
1727 assert!(action.description.as_ref().unwrap().contains("Ω"));
1728 }
1729
1730 #[test]
1731 fn composio_malformed_json_returns_error() {
1732 let json_str = r#"{"name": "TEST_ACTION", "appName": "gmail", }"#;
1733 let result: Result<ComposioAction, _> = serde_json::from_str(json_str);
1734 assert!(result.is_err());
1735 }
1736
1737 #[test]
1738 fn composio_empty_json_string_returns_error() {
1739 let json_str = r#" ""#;
1740 let result: Result<ComposioAction, _> = serde_json::from_str(json_str);
1741 assert!(result.is_err());
1742 }
1743
1744 #[test]
1745 fn composio_large_actions_list() {
1746 let mut items = Vec::new();
1747 for i in 0..100 {
1748 items.push(json!({
1749 "slug": format!("action-{i}"),
1750 "name": format!("ACTION_{i}"),
1751 "app_name": "test",
1752 "description": "Test action"
1753 }));
1754 }
1755 let json_str = json!({"items": items}).to_string();
1756 let resp: ComposioToolsResponse = serde_json::from_str(&json_str).unwrap();
1757 assert_eq!(resp.items.len(), 100);
1758 }
1759
1760 #[test]
1761 fn composio_api_base_url_is_v3() {
1762 assert_eq!(COMPOSIO_API_BASE_V3, "https://backend.composio.dev/api/v3");
1763 }
1764
1765 #[test]
1766 fn build_execute_action_v3_request_uses_fixed_endpoint_and_body_account_id() {
1767 let (url, body) = ComposioTool::build_execute_action_v3_request(
1768 "gmail-send-email",
1769 json!({"to": "test@example.com"}),
1770 None,
1771 Some("workspace-user"),
1772 Some("account-42"),
1773 );
1774
1775 assert_eq!(
1776 url,
1777 "https://backend.composio.dev/api/v3/tools/execute/gmail-send-email"
1778 );
1779 assert_eq!(body["arguments"]["to"], json!("test@example.com"));
1780 assert_eq!(body["version"], json!(COMPOSIO_TOOL_VERSION_LATEST));
1781 assert_eq!(body["user_id"], json!("workspace-user"));
1782 assert_eq!(body["connected_account_id"], json!("account-42"));
1783 }
1784
1785 #[test]
1786 fn build_list_actions_v3_query_requests_latest_versions() {
1787 let query = ComposioTool::build_list_actions_v3_query(None)
1788 .into_iter()
1789 .collect::<HashMap<String, String>>();
1790 assert_eq!(
1791 query.get("toolkit_versions"),
1792 Some(&COMPOSIO_TOOL_VERSION_LATEST.to_string())
1793 );
1794 assert_eq!(query.get("limit"), Some(&"200".to_string()));
1795 assert!(!query.contains_key("toolkits"));
1796 assert!(!query.contains_key("toolkit_slug"));
1797 }
1798
1799 #[test]
1800 fn build_list_actions_v3_query_adds_app_filters_when_present() {
1801 let query = ComposioTool::build_list_actions_v3_query(Some(" github "))
1802 .into_iter()
1803 .collect::<HashMap<String, String>>();
1804 assert_eq!(
1805 query.get("toolkit_versions"),
1806 Some(&COMPOSIO_TOOL_VERSION_LATEST.to_string())
1807 );
1808 assert_eq!(query.get("toolkits"), Some(&"github".to_string()));
1809 assert_eq!(query.get("toolkit_slug"), Some(&"github".to_string()));
1810 }
1811
1812 #[test]
1815 fn resolve_picks_first_usable_when_multiple_accounts_exist() {
1816 let accounts = vec![
1819 ComposioConnectedAccount {
1820 id: "ca_old".to_string(),
1821 status: "ACTIVE".to_string(),
1822 toolkit: None,
1823 },
1824 ComposioConnectedAccount {
1825 id: "ca_new".to_string(),
1826 status: "ACTIVE".to_string(),
1827 toolkit: None,
1828 },
1829 ];
1830 let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1832 assert_eq!(resolved.as_deref(), Some("ca_old"));
1833 }
1834
1835 #[test]
1836 fn resolve_picks_first_usable_skipping_unusable_head() {
1837 let accounts = vec![
1838 ComposioConnectedAccount {
1839 id: "ca_dead".to_string(),
1840 status: "DISCONNECTED".to_string(),
1841 toolkit: None,
1842 },
1843 ComposioConnectedAccount {
1844 id: "ca_live".to_string(),
1845 status: "ACTIVE".to_string(),
1846 toolkit: None,
1847 },
1848 ];
1849 let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1850 assert_eq!(resolved.as_deref(), Some("ca_live"));
1851 }
1852
1853 #[test]
1854 fn resolve_returns_none_when_no_usable_accounts() {
1855 let accounts = vec![ComposioConnectedAccount {
1856 id: "ca_dead".to_string(),
1857 status: "DISCONNECTED".to_string(),
1858 toolkit: None,
1859 }];
1860 let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1861 assert!(resolved.is_none());
1862 }
1863
1864 #[test]
1865 fn resolve_returns_none_for_empty_accounts() {
1866 let accounts: Vec<ComposioConnectedAccount> = vec![];
1867 let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1868 assert!(resolved.is_none());
1869 }
1870
1871 #[tokio::test]
1874 async fn connected_accounts_alias_dispatches_same_as_list_accounts() {
1875 let tool = ComposioTool::new("test-key", None, test_security());
1878 let r1 = tool
1879 .execute(json!({"action": "list_accounts"}))
1880 .await
1881 .unwrap();
1882 let r2 = tool
1883 .execute(json!({"action": "connected_accounts"}))
1884 .await
1885 .unwrap();
1886 assert!(!r1.success);
1888 assert!(!r2.success);
1889 let e1 = r1.error.unwrap_or_default();
1890 let e2 = r2.error.unwrap_or_default();
1891 assert!(!e1.contains("Unknown action"), "list_accounts: {e1}");
1892 assert!(!e2.contains("Unknown action"), "connected_accounts: {e2}");
1893 }
1894
1895 #[test]
1896 fn schema_enum_includes_connected_accounts_alias() {
1897 let tool = ComposioTool::new("test-key", None, test_security());
1898 let schema = tool.parameters_schema();
1899 let values: Vec<&str> = schema["properties"]["action"]["enum"]
1900 .as_array()
1901 .unwrap()
1902 .iter()
1903 .filter_map(|v| v.as_str())
1904 .collect();
1905 assert!(values.contains(&"connected_accounts"));
1906 assert!(values.contains(&"list_accounts"));
1907 }
1908
1909 #[test]
1910 fn description_mentions_connected_accounts() {
1911 let tool = ComposioTool::new("test-key", None, test_security());
1912 assert!(tool.description().contains("connected_accounts"));
1913 }
1914
1915 #[test]
1916 fn build_execute_action_v3_request_drops_blank_optional_fields() {
1917 let (url, body) = ComposioTool::build_execute_action_v3_request(
1918 "github-list-repos",
1919 json!({}),
1920 None,
1921 None,
1922 Some(" "),
1923 );
1924
1925 assert_eq!(
1926 url,
1927 "https://backend.composio.dev/api/v3/tools/execute/github-list-repos"
1928 );
1929 assert_eq!(body["arguments"], json!({}));
1930 assert_eq!(body["version"], json!(COMPOSIO_TOOL_VERSION_LATEST));
1931 assert!(body.get("connected_account_id").is_none());
1932 assert!(body.get("user_id").is_none());
1933 }
1934}