//! Raw-line oriented coverage for deterministic Composio helpers. //! //! These tests avoid live Composio/backend calls and exercise public helper //! surfaces that feed the JSON-RPC and agent-tool paths. use std::sync::{Arc, Mutex}; use axum::body::to_bytes; use axum::extract::{Request, State}; use axum::http::{Method, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::any; use axum::{Json, Router}; use serde_json::{json, Value}; use tempfile::tempdir; use openhuman_core::core::all::RegisteredController; use openhuman_core::openhuman::composio::client::{ create_composio_client, direct_execute, ComposioClientKind, }; use openhuman_core::openhuman::composio::error_mapping::{ classify_composio_error, format_provider_error, remap_transport_error, ComposioErrorClass, }; use openhuman_core::openhuman::composio::execute_dispatch::{ execute_composio_action, execute_composio_action_kind, }; use openhuman_core::openhuman::composio::execute_prepare::prepare_execute_arguments; use openhuman_core::openhuman::composio::oauth_handoff::{ clear_non_active_connections, is_authorize_rate_limited, is_clearable_oauth_status, is_inflight_oauth_status, is_meta_oauth_toolkit, meta_oauth_rate_limit_message, wrap_authorize_rate_limit_error, }; use openhuman_core::openhuman::composio::providers::{ classify_unknown, find_curated, toolkit_from_slug, CuratedTool, ToolScope, UserScopePref, }; use openhuman_core::openhuman::composio::tools::{ ComposioAction, ComposioAuthorizeTool, ComposioConnectedAccount, ComposioExecuteTool, ComposioListConnectionsTool, ComposioListToolkitsTool, ComposioListToolsTool, }; use openhuman_core::openhuman::composio::trigger_history::ComposioTriggerHistoryStore; use openhuman_core::openhuman::composio::types::{ ComposioActiveTrigger, ComposioActiveTriggersResponse, ComposioAgentReadyToolkitsResponse, ComposioAuthorizeResponse, ComposioAvailableTrigger, ComposioAvailableTriggerRepo, ComposioAvailableTriggersResponse, ComposioCapabilitiesResponse, ComposioCapability, ComposioConnection, ComposioConnectionsResponse, ComposioCreateTriggerResponse, ComposioDeleteResponse, ComposioDisableTriggerResponse, ComposioEnableTriggerResponse, ComposioExecuteResponse, ComposioGithubRepo, ComposioGithubReposResponse, ComposioToolFunction, ComposioToolSchema, ComposioToolkitsResponse, ComposioToolsResponse, ComposioTriggerEvent, ComposioTriggerHistoryEntry, ComposioTriggerHistoryResult, ComposioTriggerMetadata, }; use openhuman_core::openhuman::composio::{ all_composio_agent_tools, all_composio_controller_schemas, all_composio_registered_controllers, cached_active_integrations, connected_set_hash, connection_identity, fetch_connected_integrations, fetch_connected_integrations_status, init_composio_trigger_history, invalidate_connected_integrations_cache, ComposioActionTool, ComposioClient, FetchConnectedIntegrationsStatus, }; use openhuman_core::openhuman::config::Config; use openhuman_core::openhuman::context::prompt::ConnectedIntegration; use openhuman_core::openhuman::credentials::{ AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, }; use openhuman_core::openhuman::integrations::IntegrationClient; use openhuman_core::openhuman::security::{AutonomyLevel, SecurityPolicy}; use openhuman_core::openhuman::tools::{ ComposioTool, PermissionLevel, Tool, ToolCallOptions, ToolCategory, }; #[test] fn composio_prepare_execute_arguments_normalizes_calendar_and_notion_payloads() { let calendar = prepare_execute_arguments( " GOOGLECALENDAR_EVENTS_LIST ", Some(json!({ "timeMin": "2026-05-29", "time_max": "2026-05-30T15:00:00-07:00" })), ) .expect("calendar args should normalize"); assert_eq!( calendar.get("timeMin").and_then(Value::as_str), Some("2026-05-29T00:00:00Z") ); assert_eq!( calendar.get("time_max").and_then(Value::as_str), Some("2026-05-30T15:00:00-07:00") ); let invalid_date = prepare_execute_arguments( "GOOGLECALENDAR_FIND_EVENT", Some(json!({ "timeMax": "2026-99-99" })), ) .expect_err("invalid bare dates should be rejected"); assert!(invalid_date.contains("RFC 3339 timestamp")); let notion_pages = prepare_execute_arguments( "NOTION_FETCH_DATA", Some(json!({ "filter": { "value": "page" } })), ) .expect("notion page filter should infer fetch type"); assert_eq!( notion_pages.get("fetch_type").and_then(Value::as_str), Some("pages") ); let notion_database = prepare_execute_arguments( "NOTION_FETCH_DATA", Some(json!({ "fetchType": "databases", "filter": { "property": "page" } })), ) .expect("explicit fetch type should win"); assert_eq!(notion_database.get("fetch_type"), None); assert_eq!( notion_database.get("fetchType").and_then(Value::as_str), Some("databases") ); } #[test] fn composio_prepare_execute_arguments_validates_gmail_mutations() { let empty = prepare_execute_arguments("GMAIL_SEND_EMAIL", None) .expect_err("gmail send needs a recipient"); assert!(empty.contains("recipient")); let send = prepare_execute_arguments( "GMAIL_SEND_EMAIL", Some(json!({ "recipientEmail": "person@example.test", "subject": "Hi" })), ) .expect("recipientEmail alias should be accepted"); assert_eq!( send.get("recipientEmail").and_then(Value::as_str), Some("person@example.test") ); let missing_message = prepare_execute_arguments( "GMAIL_ADD_LABEL_TO_EMAIL", Some(json!({ "addLabelIds": ["Label_1"] })), ) .expect_err("gmail add label needs a message id"); assert!(missing_message.contains("message_id")); let missing_labels = prepare_execute_arguments( "GMAIL_ADD_LABEL_TO_EMAIL", Some(json!({ "messageId": "msg-1", "addLabelIds": [" "] })), ) .expect_err("gmail add label needs at least one non-empty label"); assert!(missing_labels.contains("at least one")); let labeled = prepare_execute_arguments( "GMAIL_ADD_LABEL_TO_EMAIL", Some(json!({ "messageId": "msg-1", "remove_label_ids": "Label_2" })), ) .expect("string label alias should be accepted"); assert_eq!( labeled.get("messageId").and_then(Value::as_str), Some("msg-1") ); let non_object = prepare_execute_arguments("GMAIL_SEND_EMAIL", Some(json!("bad"))) .expect_err("arguments must be an object"); assert!(non_object.contains("must be a JSON object")); } #[test] fn composio_error_mapping_classifies_and_formats_provider_failures() { assert_eq!(ComposioErrorClass::Validation.as_str(), "validation"); assert_eq!( ComposioErrorClass::InsufficientScope.as_str(), "insufficient_scope" ); assert_eq!(ComposioErrorClass::RateLimited.as_str(), "rate_limited"); assert_eq!( ComposioErrorClass::UpstreamProvider.as_str(), "upstream_provider" ); assert_eq!( ComposioErrorClass::ComposioPlatform.as_str(), "composio_platform" ); assert_eq!(ComposioErrorClass::Gateway.as_str(), "gateway"); assert_eq!(ComposioErrorClass::Other.as_str(), "other"); assert_eq!( classify_composio_error("GMAIL_SEND_EMAIL", "missing required field to"), ComposioErrorClass::Validation ); assert_eq!( classify_composio_error( "GMAIL_FETCH_EMAILS", "403 insufficient authentication scopes for Gmail" ), ComposioErrorClass::InsufficientScope ); assert_eq!( classify_composio_error("SLACK_POST_MESSAGE", "429 too many requests"), ComposioErrorClass::RateLimited ); assert_eq!( classify_composio_error("GMAIL_FETCH_EMAILS", "Mailbox provider exploded"), ComposioErrorClass::UpstreamProvider ); assert_eq!( classify_composio_error("CUSTOM_ACTION", "connection error, try to authenticate"), ComposioErrorClass::ComposioPlatform ); assert_eq!( classify_composio_error("CUSTOM_ACTION", "502 Bad Gateway"), ComposioErrorClass::Gateway ); assert_eq!( classify_composio_error("CUSTOM_ACTION", "plain unknown failure"), ComposioErrorClass::Other ); let scope = format_provider_error( "GMAIL_FETCH_EMAILS", "insufficient authentication scopes: gmail.readonly", ); assert!(scope.starts_with("[composio:error:insufficient_scope]")); assert!(scope.contains("Reconnect the integration")); let gateway = remap_transport_error( "GMAIL_FETCH_EMAILS", "Backend returned 502 Bad Gateway for POST: {\"error\":\"insufficient scope\"}", ); assert!( gateway.starts_with("[composio:error:insufficient_scope]"), "embedded provider errors should not be bucketed as gateway: {gateway}" ); let summarized_gateway = remap_transport_error( "CUSTOM_ACTION", "request failed: Backend returned 504 Gateway Timeout for POST /execute: edge timeout", ); assert!(summarized_gateway.starts_with("[composio:error:gateway]")); assert!(summarized_gateway.contains("edge timeout")); let raw_gateway = remap_transport_error("CUSTOM_ACTION", "502 Bad Gateway"); assert!(raw_gateway.contains("502 Bad Gateway")); let rate_limited = format_provider_error("SLACK_FETCH_CONVERSATION_HISTORY", "429"); assert!(rate_limited.starts_with("[composio:error:rate_limited]")); assert!(rate_limited.contains("not an OpenHuman gateway outage")); let platform = format_provider_error("CUSTOM_ACTION", "token revoked"); assert!(platform.starts_with("[composio:error:composio_platform]")); let validation_transport = remap_transport_error( "GMAIL_SEND_EMAIL", "Backend returned 502 Bad Gateway: missing required field `to`", ); assert!(validation_transport.starts_with("[composio:error:validation]")); } #[test] fn composio_oauth_handoff_helpers_classify_meta_status_and_rate_limits() { assert!(is_meta_oauth_toolkit(" Instagram ")); assert!(is_meta_oauth_toolkit("FACEBOOK")); assert!(!is_meta_oauth_toolkit("gmail")); for status in ["pending", "INITIATED", " initializing "] { assert!( is_inflight_oauth_status(status), "{status} should be inflight" ); assert!( is_clearable_oauth_status(status), "{status} should be clearable" ); } for status in ["failed", "ERROR", " expired "] { assert!(!is_inflight_oauth_status(status)); assert!(is_clearable_oauth_status(status)); } assert!(!is_clearable_oauth_status("ACTIVE")); for message in ["HTTP 429", "too many requests", "rate_limit", "ratelimited"] { assert!(is_authorize_rate_limited(message)); } assert!(!is_authorize_rate_limited("plain auth failure")); let instagram = meta_oauth_rate_limit_message("instagram"); assert!(instagram.contains("Instagram Business or Creator")); let facebook = meta_oauth_rate_limit_message("facebook"); assert!(facebook.contains("Business Manager")); let unknown = meta_oauth_rate_limit_message("threads"); assert!(!unknown.contains("Business Manager")); let wrapped = wrap_authorize_rate_limit_error("instagram", anyhow::anyhow!("429 too many requests")); assert!(wrapped.to_string().contains("temporarily rate-limiting")); let passthrough = wrap_authorize_rate_limit_error("gmail", anyhow::anyhow!("429")); assert_eq!(passthrough.to_string(), "429"); } #[tokio::test] async fn composio_connected_integrations_public_helpers_handle_empty_auth_and_identity_edges() { let dir = tempdir().expect("tempdir"); let config = Config { workspace_dir: dir.path().to_path_buf(), config_path: dir.path().join("config.toml"), ..Config::default() }; invalidate_connected_integrations_cache(); assert!(cached_active_integrations(&config).is_none()); let first = ConnectedIntegration { toolkit: "gmail".into(), description: "Gmail".into(), tools: Vec::new(), gated_tools: Vec::new(), connected: true, connections: Vec::new(), non_active_status: None, }; let second = ConnectedIntegration { toolkit: "slack".into(), description: "Slack".into(), tools: Vec::new(), gated_tools: Vec::new(), connected: true, connections: Vec::new(), non_active_status: None, }; let disconnected = ConnectedIntegration { toolkit: "notion".into(), description: "Notion".into(), tools: Vec::new(), gated_tools: Vec::new(), connected: false, connections: Vec::new(), non_active_status: Some("EXPIRED".into()), }; assert_eq!( connected_set_hash(&[first.clone(), second.clone(), disconnected.clone()]), connected_set_hash(&[disconnected, second, first]) ); assert_ne!( connected_set_hash(&[]), connected_set_hash(&[ConnectedIntegration { toolkit: "gmail".into(), description: String::new(), tools: Vec::new(), gated_tools: Vec::new(), connected: true, connections: Vec::new(), non_active_status: None, }]) ); let status = fetch_connected_integrations_status(&config).await; assert!(matches!( status, FetchConnectedIntegrationsStatus::Unavailable )); assert!(fetch_connected_integrations(&config).await.is_empty()); assert!(cached_active_integrations(&config).is_none()); assert_eq!(connection_identity(&config, " ").await, None); assert_eq!(connection_identity(&config, "unknown-toolkit").await, None); } #[tokio::test] async fn composio_ops_mode_and_trigger_history_are_local_and_deterministic() { let dir = tempdir().expect("tempdir"); let mut config = Config { workspace_dir: dir.path().to_path_buf(), config_path: dir.path().join("config.toml"), ..Config::default() }; config.composio.mode = "direct".into(); let mode = openhuman_core::openhuman::composio::ops::composio_get_mode(&config) .await .expect("get mode should not call backend") .into_cli_compatible_json() .expect("mode outcome serializes"); assert_eq!(mode.pointer("/result/mode"), Some(&json!("direct"))); assert!(mode.pointer("/result/api_key_set").is_some()); init_composio_trigger_history(dir.path().to_path_buf()) .expect("global trigger history initializes for temp workspace"); let store = openhuman_core::openhuman::composio::global_composio_trigger_history() .expect("global history store"); store .record_trigger( "gmail", "GMAIL_NEW_GMAIL_MESSAGE", "metadata-local", "uuid-local", &json!({ "subject": "ops coverage" }), ) .expect("record global trigger"); let history = openhuman_core::openhuman::composio::ops::composio_list_trigger_history(&config, Some(0)) .await .expect("history listing is local") .into_cli_compatible_json() .expect("history outcome serializes"); assert_eq!( history.pointer("/result/entries/0/metadata_id"), Some(&json!("metadata-local")) ); assert!(history .pointer("/result/archive_dir") .and_then(Value::as_str) .unwrap_or_default() .contains("state/triggers")); } #[test] fn composio_action_tool_metadata_is_stable_without_network_execution() { let dir = tempdir().expect("tempdir"); let config = Config { workspace_dir: dir.path().to_path_buf(), config_path: dir.path().join("config.toml"), ..Config::default() }; let tool = ComposioActionTool::new( Arc::new(config), "GMAIL_SEND_EMAIL".into(), "Send an email".into(), Some(json!({ "type": "object", "properties": { "to": { "type": "string" } } })), ); assert_eq!(tool.name(), "GMAIL_SEND_EMAIL"); assert_eq!(tool.description(), "Send an email"); assert_eq!(tool.permission_level(), PermissionLevel::Write); assert_eq!(tool.category(), ToolCategory::Workflow); assert_eq!( tool.parameters_schema().pointer("/properties/to/type"), Some(&json!("string")) ); let default_schema = ComposioActionTool::new( Arc::new(Config::default()), "NOTION_FETCH_DATA".into(), "Fetch Notion data".into(), None, ); assert_eq!( default_schema.parameters_schema(), json!({ "type": "object" }) ); } #[tokio::test] async fn composio_action_tool_execute_reports_factory_failures_without_network() { let tool = ComposioActionTool::new( Arc::new(Config::default()), "GMAIL_SEND_EMAIL".into(), "Send an email".into(), None, ); let result = tool .execute(json!({ "subject": "missing recipient" })) .await .expect("local validation returns a tool result"); assert!(result.is_error); let rendered = serde_json::to_string(&result).unwrap(); assert!(rendered.contains("no backend session token")); } #[tokio::test] async fn composio_client_and_dispatch_reject_invalid_inputs_before_network() { let inner = Arc::new(IntegrationClient::new( "http://127.0.0.1:0".into(), "test-token".into(), )); let client = ComposioClient::new(inner); let clone = client.clone(); assert!(Arc::ptr_eq(client.inner(), clone.inner())); let auth_empty = client.authorize(" ", None).await.unwrap_err(); assert!(auth_empty.to_string().contains("toolkit must not be empty")); let auth_non_object = client .authorize("whatsapp", Some(json!("waba-123"))) .await .unwrap_err(); assert!(auth_non_object .to_string() .contains("extra_params must be a JSON object")); let auth_reserved = client .authorize("whatsapp", Some(json!({ "client_id": "bad" }))) .await .unwrap_err(); assert!(auth_reserved .to_string() .contains("cannot override reserved key")); let delete_empty = client.delete_connection(" ").await.unwrap_err(); assert!(delete_empty .to_string() .contains("connectionId must not be empty")); let execute_empty = client.execute_tool("\t", None).await.unwrap_err(); assert!(execute_empty .to_string() .contains("tool slug must not be empty")); let create_empty = client.create_trigger(" ", None, None).await.unwrap_err(); assert!(create_empty.to_string().contains("slug must not be empty")); let available_empty = client .list_available_triggers(" ", Some("conn-1")) .await .unwrap_err(); assert!(available_empty .to_string() .contains("toolkit must not be empty")); let enable_missing_connection = client .enable_trigger(" ", "GMAIL_NEW_GMAIL_MESSAGE", None) .await .unwrap_err(); assert!(enable_missing_connection .to_string() .contains("connectionId must not be empty")); let enable_missing_slug = client .enable_trigger("conn-1", " ", None) .await .unwrap_err(); assert!(enable_missing_slug .to_string() .contains("slug must not be empty")); let disable_empty = client.disable_trigger("").await.unwrap_err(); assert!(disable_empty .to_string() .contains("triggerId must not be empty")); let dispatch_empty = execute_composio_action(&client, " ", None) .await .unwrap_err(); assert!(dispatch_empty.contains("tool slug must not be empty")); let dispatch_validation = execute_composio_action( &client, "GMAIL_SEND_EMAIL", Some(json!({ "subject": "missing recipient" })), ) .await .unwrap_err(); assert!(dispatch_validation.starts_with("[composio:error:")); assert!(dispatch_validation.contains("recipient")); let backend_kind = ComposioClientKind::Backend(client.clone()); assert_eq!(backend_kind.mode(), "backend"); let kind_empty = execute_composio_action_kind(backend_kind, " ", None, "entity") .await .unwrap_err(); assert!(kind_empty.contains("tool slug must not be empty")); let kind_validation = execute_composio_action_kind( ComposioClientKind::Backend(client), "GMAIL_SEND_EMAIL", Some(json!({ "subject": "missing recipient" })), "entity", ) .await .unwrap_err(); assert!(kind_validation.starts_with("[composio:error:")); assert!(kind_validation.contains("recipient")); let direct_tool = Arc::new(ComposioTool::new( "direct-key", Some("entity-1"), Arc::new(SecurityPolicy::default()), )); let direct_kind = ComposioClientKind::Direct(direct_tool); assert_eq!(direct_kind.mode(), "direct"); let direct_validation = execute_composio_action_kind( direct_kind, "GMAIL_SEND_EMAIL", Some(json!({ "subject": "still missing recipient" })), "entity-1", ) .await .expect_err("direct dispatch validates before network"); assert!(direct_validation.starts_with("[composio:error:")); assert!(direct_validation.contains("recipient")); } #[test] fn composio_client_factory_modes_are_deterministic_without_network() { let dir = tempdir().expect("tempdir"); let mut config = Config { workspace_dir: dir.path().to_path_buf(), config_path: dir.path().join("config.toml"), ..Config::default() }; config.composio.mode = String::new(); let backend_err = match create_composio_client(&config) { Ok(_) => panic!("backend without a session should fail"), Err(error) => error, }; assert!(backend_err.to_string().contains("no backend session token")); config.composio.mode = "direct".into(); let direct_err = match create_composio_client(&config) { Ok(_) => panic!("direct mode without an api key should fail"), Err(error) => error, }; assert!(direct_err.to_string().contains("no api key is configured")); config.composio.api_key = Some(" cmp_test_key ".into()); let direct = create_composio_client(&config).expect("inline direct key builds a client"); assert_eq!(direct.mode(), "direct"); assert!(matches!(direct, ComposioClientKind::Direct(_))); config.composio.mode = "typo".into(); let unknown = match create_composio_client(&config) { Ok(_) => panic!("unknown composio mode should fail"), Err(error) => error, }; assert!(unknown.to_string().contains("unknown composio mode")); } #[tokio::test] async fn composio_backend_client_local_validation_rejects_bad_inputs_before_http() { let client = ComposioClient::new(Arc::new(IntegrationClient::new( "http://127.0.0.1:9".to_string(), "unused-token".to_string(), ))); let blank_authorize = client .authorize(" ", None) .await .expect_err("blank toolkit should fail before HTTP"); assert!(blank_authorize .to_string() .contains("toolkit must not be empty")); let non_object_extra = client .authorize("gmail", Some(json!("bad"))) .await .expect_err("extra params must be an object"); assert!(non_object_extra .to_string() .contains("extra_params must be a JSON object")); let reserved_extra = client .authorize("gmail", Some(json!({ "toolkit": "slack" }))) .await .expect_err("reserved keys cannot be overridden"); assert!(reserved_extra .to_string() .contains("cannot override reserved key")); let blank_delete = client .delete_connection("\t") .await .expect_err("blank connection id should fail before HTTP"); assert!(blank_delete.to_string().contains("connectionId")); let blank_execute = client .execute_tool(" ", Some(json!({}))) .await .expect_err("blank tool should fail before HTTP"); assert!(blank_execute.to_string().contains("tool slug")); let blank_create = client .create_trigger(" ", None, None) .await .expect_err("blank trigger slug should fail before HTTP"); assert!(blank_create.to_string().contains("slug must not be empty")); let blank_available = client .list_available_triggers(" ", None) .await .expect_err("blank toolkit should fail before HTTP"); assert!(blank_available.to_string().contains("toolkit")); let blank_enable_connection = client .enable_trigger(" ", "GMAIL_NEW_GMAIL_MESSAGE", None) .await .expect_err("blank connection id should fail before HTTP"); assert!(blank_enable_connection.to_string().contains("connectionId")); let blank_enable_slug = client .enable_trigger("conn-1", " ", None) .await .expect_err("blank trigger slug should fail before HTTP"); assert!(blank_enable_slug.to_string().contains("slug")); let blank_disable = client .disable_trigger(" ") .await .expect_err("blank trigger id should fail before HTTP"); assert!(blank_disable.to_string().contains("triggerId")); let direct_tool = Arc::new(ComposioTool::new( "direct-api-key", Some("entity-1"), Arc::new(SecurityPolicy::default()), )); let blank_direct_execute = direct_execute(&direct_tool, " ", None, "entity-1", None) .await .expect_err("blank direct tool should fail before HTTP"); assert!(blank_direct_execute.to_string().contains("tool slug")); } #[tokio::test] async fn composio_backend_client_surfaces_get_post_envelope_and_status_errors() { async fn handler(request: Request) -> Response { let method = request.method().clone(); let path = request.uri().path().to_string(); match (method, path.as_str()) { (Method::GET, "/agent-integrations/composio/toolkits") => Json(json!({ "success": false, "error": "Toolkit allowlist unavailable" })) .into_response(), (Method::GET, "/agent-integrations/composio/tools") => { Json(json!({ "success": true })).into_response() } (Method::POST, "/agent-integrations/composio/authorize") => { Json(json!({ "success": true })).into_response() } (Method::POST, "/agent-integrations/composio/execute") => ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({ "success": false, "error": "upstream maintenance" })), ) .into_response(), _ => ( StatusCode::NOT_FOUND, Json(json!({ "success": false, "error": format!("unhandled {path}") })), ) .into_response(), } } let base = start_composio_round8_backend(Router::new().fallback(any(handler))).await; let client = ComposioClient::new(Arc::new(IntegrationClient::new( base, "round13-token".into(), ))); let toolkits = client .list_toolkits() .await .expect_err("success=false GET envelopes should error"); assert!(toolkits.to_string().contains("Backend error for GET")); assert!(toolkits .to_string() .contains("Toolkit allowlist unavailable")); let tools = client .list_tools(None, None) .await .expect_err("success=true without data should error"); assert!(tools .to_string() .contains("Backend returned success but no data for GET")); let authorize = client .authorize("slack", None) .await .expect_err("POST success=true without data should error"); assert!(authorize .to_string() .contains("Backend returned success but no data for POST")); let execute = client .execute_tool("SLACK_POST_MESSAGE", Some(json!({ "text": "hello" }))) .await .expect_err("non-2xx POST should error"); assert!(execute.to_string().contains("Backend returned 503")); assert!(execute.to_string().contains("upstream maintenance")); } #[tokio::test] async fn composio_backend_factory_uses_stored_session_and_configured_backend() { async fn handler(request: Request) -> Response { let auth = request .headers() .get("authorization") .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_string(); let method = request.method().clone(); let path = request.uri().path().to_string(); if auth != "Bearer stored-session-token" { return ( StatusCode::UNAUTHORIZED, Json(json!({ "success": false, "error": format!("bad auth: {auth}") })), ) .into_response(); } match (method, path.as_str()) { (Method::GET, "/agent-integrations/composio/toolkits") => ok(json!({ "toolkits": ["gmail"] })), _ => ( StatusCode::NOT_FOUND, Json(json!({ "success": false, "error": format!("unhandled {path}") })), ) .into_response(), } } let base = start_composio_round8_backend(Router::new().fallback(any(handler))).await; let dir = tempdir().expect("tempdir"); let mut config = Config { workspace_dir: dir.path().join("workspace"), config_path: dir.path().join("config.toml"), api_url: Some(base.clone()), ..Config::default() }; config.composio.mode = "backend".into(); store_app_session_token(&config, " stored-session-token "); let client = match create_composio_client(&config).expect("backend client from stored session") { ComposioClientKind::Backend(client) => client, ComposioClientKind::Direct(_) => panic!("backend mode should not create direct client"), }; assert_eq!(client.inner().backend_url, base); let toolkits = client .list_toolkits() .await .expect("factory client should call local backend with stored bearer"); assert_eq!(toolkits.toolkits, vec!["gmail"]); } #[tokio::test] async fn composio_controller_registry_and_scope_handlers_cover_validation_edges() { let schemas = all_composio_controller_schemas(); let registered = all_composio_registered_controllers(); assert_eq!(schemas.len(), registered.len()); assert!(schemas.iter().any(|schema| schema.function == "execute")); assert!(schemas .iter() .any(|schema| schema.function == "set_api_key")); assert!(registered.iter().all(|controller| { controller .rpc_method_name() .starts_with("openhuman.composio_") })); let unknown = openhuman_core::openhuman::composio::schemas::schemas("not_real"); assert_eq!(unknown.function, "unknown"); assert_eq!(unknown.inputs[0].name, "function"); let get_scopes = composio_controller(®istered, "get_user_scopes"); let scopes = composio_call(get_scopes, json!({ "toolkit": " Gmail " })) .await .expect("default user scopes"); assert_eq!(scopes.pointer("/read"), Some(&json!(true))); assert_eq!(scopes.pointer("/write"), Some(&json!(true))); assert_eq!(scopes.pointer("/admin"), Some(&json!(false))); let missing_toolkit = composio_call(get_scopes, json!({})) .await .expect_err("toolkit is required"); assert!(missing_toolkit.contains("missing required param 'toolkit'")); let set_scopes = composio_controller(®istered, "set_user_scopes"); let invalid_write = composio_call( set_scopes, json!({ "toolkit": "gmail", "read": true, "write": "yes", "admin": false }), ) .await .expect_err("write must be bool"); assert!(invalid_write.contains("invalid 'write'")); let memory_missing = composio_call( set_scopes, json!({ "toolkit": "gmail", "read": true, "write": true, "admin": false }), ) .await .expect_err("memory client not initialised"); assert!(memory_missing.contains("memory client not initialised")); } #[test] fn composio_controller_schema_catalog_covers_all_declared_functions() { let expected = [ ("list_toolkits", 0, "toolkits"), ("list_capabilities", 0, "capabilities"), ("list_agent_ready_toolkits", 0, "toolkits"), ("list_connections", 0, "connections"), ("authorize", 2, "connectUrl"), ("delete_connection", 2, "deleted"), ("list_tools", 2, "tools"), ("execute", 3, "result"), ("list_github_repos", 1, "result"), ("create_trigger", 3, "result"), ("get_user_profile", 1, "profile"), ("refresh_all_identities", 0, "report"), ("sync", 2, "outcome"), ("list_trigger_history", 1, "result"), ("get_user_scopes", 1, "pref"), ("set_user_scopes", 4, "pref"), ("list_available_triggers", 2, "triggers"), ("list_triggers", 1, "triggers"), ("enable_trigger", 3, "result"), ("disable_trigger", 1, "deleted"), ("get_mode", 0, "mode"), ("set_api_key", 2, "result"), ("clear_api_key", 0, "result"), ]; for (function, input_count, first_output) in expected { let schema = openhuman_core::openhuman::composio::schemas::schemas(function); assert_eq!(schema.namespace, "composio"); assert_eq!(schema.function, function); assert_eq!(schema.inputs.len(), input_count, "{function}"); assert_eq!(schema.outputs[0].name, first_output, "{function}"); assert!(!schema.description.is_empty()); } } #[tokio::test] async fn composio_controller_handlers_reject_bad_params_before_network() { let registered = all_composio_registered_controllers(); let missing_authorize = composio_call(composio_controller(®istered, "authorize"), json!({})) .await .expect_err("authorize requires toolkit"); assert!(missing_authorize.contains("missing required param 'toolkit'")); let blank_delete = composio_call( composio_controller(®istered, "delete_connection"), json!({ "connection_id": " " }), ) .await .expect_err("delete requires non-empty connection"); assert!(blank_delete.contains("'connection_id' must not be empty")); let invalid_list_tools = composio_call( composio_controller(®istered, "list_tools"), json!({ "toolkits": "gmail" }), ) .await .expect_err("toolkits must be an array"); assert!(invalid_list_tools.contains("invalid 'toolkits'")); let missing_execute = composio_call(composio_controller(®istered, "execute"), json!({})) .await .expect_err("execute requires tool"); assert!(missing_execute.contains("missing required param 'tool'")); let blank_create = composio_call( composio_controller(®istered, "create_trigger"), json!({ "slug": " " }), ) .await .expect_err("create trigger rejects blank slug"); assert!(blank_create.contains("'slug' must not be empty")); let missing_profile = composio_call( composio_controller(®istered, "get_user_profile"), json!({}), ) .await .expect_err("profile requires connection id"); assert!(missing_profile.contains("missing required param 'connection_id'")); let missing_sync = composio_call(composio_controller(®istered, "sync"), json!({})) .await .expect_err("sync requires connection id"); assert!(missing_sync.contains("missing required param 'connection_id'")); let blank_available = composio_call( composio_controller(®istered, "list_available_triggers"), json!({ "toolkit": " " }), ) .await .expect_err("available triggers rejects blank toolkit"); assert!(blank_available.contains("'toolkit' must not be empty")); let missing_enable_connection = composio_call( composio_controller(®istered, "enable_trigger"), json!({ "connection_id": " ", "slug": "GMAIL_NEW_GMAIL_MESSAGE" }), ) .await .expect_err("enable trigger rejects blank connection"); assert!(missing_enable_connection.contains("'connection_id' must not be empty")); let missing_disable = composio_call( composio_controller(®istered, "disable_trigger"), json!({}), ) .await .expect_err("disable trigger requires id"); assert!(missing_disable.contains("missing required param 'trigger_id'")); let bad_set_key = composio_call( composio_controller(®istered, "set_api_key"), json!({ "api_key": "" }), ) .await .expect_err("set api key requires non-empty key"); assert!(bad_set_key.contains("'api_key' must not be empty")); let bad_github_repos = composio_call( composio_controller(®istered, "list_github_repos"), json!({ "connection_id": 42 }), ) .await .expect_err("github repos connection id must be string"); assert!(bad_github_repos.contains("invalid params")); let bad_history_limit = composio_call( composio_controller(®istered, "list_trigger_history"), json!({ "limit": "many" }), ) .await .expect_err("history limit must be numeric"); assert!(bad_history_limit.contains("invalid params")); let bad_list_triggers = composio_call( composio_controller(®istered, "list_triggers"), json!({ "toolkit": 12 }), ) .await .expect_err("list triggers toolkit must be string"); assert!(bad_list_triggers.contains("invalid params")); let missing_enable_slug = composio_call( composio_controller(®istered, "enable_trigger"), json!({ "connection_id": "conn-1", "slug": " " }), ) .await .expect_err("enable trigger rejects blank slug"); assert!(missing_enable_slug.contains("'slug' must not be empty")); } fn composio_controller<'a>( controllers: &'a [RegisteredController], function: &str, ) -> &'a RegisteredController { controllers .iter() .find(|controller| controller.schema.function == function) .unwrap_or_else(|| panic!("controller {function} registered")) } async fn composio_call(controller: &RegisteredController, params: Value) -> Result { let params = params.as_object().cloned().unwrap_or_default(); (controller.handler)(params).await } #[tokio::test] async fn composio_agent_tools_cover_metadata_missing_params_and_scope_helpers() { let dir = tempdir().expect("tempdir"); let config = Config { workspace_dir: dir.path().to_path_buf(), config_path: dir.path().join("config.toml"), ..Config::default() }; let config = Arc::new(config); let list_toolkits = ComposioListToolkitsTool::new(config.clone()); assert_eq!(list_toolkits.name(), "composio_list_toolkits"); assert_eq!(list_toolkits.permission_level(), PermissionLevel::ReadOnly); assert_eq!(list_toolkits.category(), ToolCategory::Workflow); assert_eq!( list_toolkits .parameters_schema() .pointer("/additionalProperties"), Some(&json!(false)) ); let list_connections = ComposioListConnectionsTool::new(config.clone()); assert_eq!(list_connections.name(), "composio_list_connections"); assert_eq!( list_connections.permission_level(), PermissionLevel::ReadOnly ); assert_eq!(list_connections.category(), ToolCategory::Workflow); let authorize = ComposioAuthorizeTool::new(config.clone()); assert_eq!(authorize.name(), "composio_authorize"); assert_eq!(authorize.permission_level(), PermissionLevel::Write); assert_eq!( authorize.parameters_schema().pointer("/required/0"), Some(&json!("toolkit")) ); let auth_missing = authorize.execute(json!({})).await.expect("missing toolkit"); assert!(auth_missing.is_error); assert!(serde_json::to_string(&auth_missing) .unwrap() .contains("'toolkit' is required")); let list_tools = ComposioListToolsTool::new(config.clone()); assert_eq!(list_tools.name(), "composio_list_tools"); assert!(list_tools.supports_markdown()); assert_eq!( list_tools .parameters_schema() .pointer("/properties/tags/items/type"), Some(&json!("string")) ); let execute = ComposioExecuteTool::new(config.clone()); assert_eq!(execute.name(), "composio_execute"); assert_eq!(execute.permission_level(), PermissionLevel::Write); assert_eq!(execute.category(), ToolCategory::Workflow); let execute_missing = execute.execute(json!({})).await.expect("missing tool"); assert!(execute_missing.is_error); assert!(serde_json::to_string(&execute_missing) .unwrap() .contains("'tool' is required")); let mut direct_config = (*config).clone(); direct_config.composio.mode = "direct".to_string(); direct_config.composio.api_key = Some("test-direct-key".to_string()); let registered_tools = all_composio_agent_tools(&direct_config); let names: Vec<&str> = registered_tools.iter().map(|tool| tool.name()).collect(); assert_eq!( names, vec![ "composio_list_toolkits", "composio_list_connections", "composio_authorize", "composio_connect", "composio_list_tools", "composio_execute", ] ); let no_tools = all_composio_agent_tools(&Config::default()); assert!(no_tools.is_empty()); assert_eq!( toolkit_from_slug(" GMAIL_SEND_EMAIL "), Some("gmail".into()) ); assert_eq!( toolkit_from_slug("noUnderscore"), Some("nounderscore".into()) ); assert_eq!(toolkit_from_slug(""), None); assert_eq!(classify_unknown("GMAIL_DELETE_EMAIL"), ToolScope::Admin); assert_eq!(classify_unknown("GMAIL_SEND_EMAIL"), ToolScope::Write); assert_eq!(classify_unknown("GMAIL_FETCH_EMAILS"), ToolScope::Read); let catalog = [ CuratedTool { slug: "GMAIL_FETCH_EMAILS", scope: ToolScope::Read, }, CuratedTool { slug: "GMAIL_SEND_EMAIL", scope: ToolScope::Write, }, ]; assert_eq!( find_curated(&catalog, "gmail_send_email") .expect("case-insensitive curated match") .scope, ToolScope::Write ); assert!(find_curated(&catalog, "GMAIL_DELETE_EMAIL").is_none()); assert_eq!(ToolScope::Admin.as_str(), "admin"); let pref = UserScopePref { read: true, write: false, admin: false, }; assert!(pref.allows(ToolScope::Read)); assert!(!pref.allows(ToolScope::Write)); assert!(!pref.allows(ToolScope::Admin)); let fallback = list_tools .execute_with_options( json!({ "toolkits": ["unknown_toolkit"], "include_unconnected": true }), ToolCallOptions { prefer_markdown: true, }, ) .await .expect("factory failure is rendered as tool result"); assert!(fallback.is_error); } #[tokio::test] async fn composio_agent_tools_direct_mode_take_local_branches_without_backend() { let dir = tempdir().expect("tempdir"); let mut config = Config { workspace_dir: dir.path().join("workspace"), config_path: dir.path().join("config.toml"), ..Config::default() }; config.composio.mode = "direct".into(); config.composio.api_key = Some("direct-test-key".into()); config .save() .await .expect("persist direct-mode test config"); let config = Arc::new(config); let list_toolkits = ComposioListToolkitsTool::new(config.clone()); let toolkits = list_toolkits .execute(json!({})) .await .expect("direct list_toolkits returns local empty response"); assert!(!toolkits.is_error); assert_eq!(toolkits.text(), r#"{"toolkits":[]}"#); let list_tools = ComposioListToolsTool::new(config.clone()); let tools = list_tools .execute_with_options( json!({ "include_unconnected": true, "tags": ["ignored outside github"] }), ToolCallOptions { prefer_markdown: true, }, ) .await .expect("direct list_tools returns local empty response"); assert!(!tools.is_error); assert_eq!(tools.text(), r#"{"tools":[]}"#); assert_eq!( tools.markdown_formatted.as_deref(), Some("_No composio tools available._") ); let authorize = ComposioAuthorizeTool::new(config.clone()); let authorize_result = authorize .execute(json!({ "toolkit": "gmail" })) .await .expect("direct authorize is refused locally"); assert!(authorize_result.is_error); assert!(authorize_result.text().contains("direct mode is active")); let execute = ComposioExecuteTool::new(config); let execute_result = execute .execute(json!({ "tool": "GMAIL_FETCH_EMAILS", "connection_id": "conn-gmail", "arguments": "not an object" })) .await .expect("direct execute validation is rendered locally"); assert!(execute_result.is_error); let execute_text = execute_result.text(); assert!( execute_text.starts_with("[composio:error:"), "{execute_text}" ); assert!(execute_text.contains("must be a JSON object")); } #[test] fn composio_types_roundtrip_connection_tool_trigger_and_history_shapes() { let toolkits: ComposioToolkitsResponse = serde_json::from_value(json!({})).unwrap(); assert!(toolkits.toolkits.is_empty()); let agent_ready = ComposioAgentReadyToolkitsResponse { toolkits: vec!["gmail".into(), "googlesheets".into()], }; assert_eq!( serde_json::to_value(&agent_ready).unwrap()["toolkits"][1], "googlesheets" ); let authorize = ComposioAuthorizeResponse { connect_url: "https://connect.example/oauth".into(), connection_id: "conn-123".into(), }; let authorize_json = serde_json::to_value(&authorize).unwrap(); assert_eq!( authorize_json["connectUrl"], "https://connect.example/oauth" ); assert_eq!(authorize_json["connectionId"], "conn-123"); let capabilities = ComposioCapabilitiesResponse { capabilities: vec![ComposioCapability { toolkit: "gmail".into(), description: "Gmail".into(), native_provider: true, curated_tools: true, curated_tool_count: 3, tool_execution: true, user_profile: true, initial_sync: true, periodic_sync: true, sync_interval_secs: Some(3600), trigger_webhooks: true, memory_ingest: true, }], }; assert_eq!( serde_json::to_value(&capabilities).unwrap()["capabilities"][0]["toolkit"], "gmail" ); let connections: ComposioConnectionsResponse = serde_json::from_value(json!({ "connections": [ { "id": "c1", "toolkit": " Gmail ", "status": " connected ", "createdAt": "2026-05-29T00:00:00Z" }, { "id": "c2", "toolkit": "slack", "status": "PENDING" } ] })) .unwrap(); assert_eq!(connections.connections[0].normalized_toolkit(), "gmail"); assert!(connections.connections[0].is_active()); assert!(!connections.connections[1].is_active()); let serialized_connection = serde_json::to_value(&connections.connections[0]).unwrap(); assert_eq!(serialized_connection["createdAt"], "2026-05-29T00:00:00Z"); let default_connection = ComposioConnection { id: "c3".into(), toolkit: "notion".into(), status: "FAILED".into(), created_at: None, account_email: None, workspace: None, username: None, }; assert!(serde_json::to_value(default_connection) .unwrap() .get("createdAt") .is_none()); let tools = ComposioToolsResponse { tools: vec![ComposioToolSchema { kind: "function".into(), function: ComposioToolFunction { name: "GMAIL_SEND_EMAIL".into(), description: Some("Send mail".into()), parameters: Some(json!({ "type": "object" })), }, }], }; assert_eq!( serde_json::to_value(&tools).unwrap()["tools"][0]["type"], "function" ); let default_kind: ComposioToolSchema = serde_json::from_value(json!({ "function": { "name": "SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL" } })) .unwrap(); assert_eq!(default_kind.kind, "function"); assert_eq!(default_kind.function.description, None); let execute: ComposioExecuteResponse = serde_json::from_value(json!({ "data": { "id": "msg-1" }, "successful": true, "costUsd": 0.03, "markdownFormatted": "**sent**" })) .unwrap(); assert!(execute.successful); assert_eq!(execute.cost_usd, 0.03); assert_eq!(execute.markdown_formatted.as_deref(), Some("**sent**")); let repos = ComposioGithubReposResponse { connection_id: "conn-github".into(), repositories: vec![ComposioGithubRepo { owner: "tinyhumansai".into(), repo: "openhuman".into(), full_name: "tinyhumansai/openhuman".into(), private: Some(false), default_branch: Some("main".into()), html_url: Some("https://github.com/tinyhumansai/openhuman".into()), }], }; assert_eq!( serde_json::to_value(&repos).unwrap()["connectionId"], "conn-github" ); let create = ComposioCreateTriggerResponse { trigger_id: "trig-1".into(), status: Some("enabled".into()), }; assert_eq!( serde_json::to_value(&create).unwrap()["triggerId"], "trig-1" ); let available = ComposioAvailableTriggersResponse { triggers: vec![ComposioAvailableTrigger { slug: "GITHUB_PULL_REQUEST_EVENT".into(), scope: "github_repo".into(), default_config: Some(json!({ "event": "pull_request" })), required_config_keys: Some(vec!["owner".into(), "repo".into()]), repo: Some(ComposioAvailableTriggerRepo { owner: "tinyhumansai".into(), repo: "openhuman".into(), }), }], }; assert_eq!( serde_json::to_value(&available).unwrap()["triggers"][0]["repo"]["repo"], "openhuman" ); let active: ComposioActiveTriggersResponse = serde_json::from_value(json!({ "triggers": [{ "id": { "id": "trigger-id" }, "slug": { "slug": "GMAIL_NEW_GMAIL_MESSAGE" }, "toolkit": { "name": "gmail" }, "connectionId": { "key": "conn-1" }, "triggerConfig": { "label": "INBOX" }, "state": { "state": "enabled" } }] })) .unwrap(); let active_trigger: &ComposioActiveTrigger = &active.triggers[0]; assert_eq!(active_trigger.id, "trigger-id"); assert_eq!(active_trigger.slug, "GMAIL_NEW_GMAIL_MESSAGE"); assert_eq!(active_trigger.toolkit, "gmail"); assert_eq!(active_trigger.connection_id, "conn-1"); assert_eq!(active_trigger.state.as_deref(), Some("enabled")); let active_without_state: ComposioActiveTrigger = serde_json::from_value(json!({ "id": "trigger-2", "slug": "SLACK_NEW_MESSAGE", "toolkit": "slack", "connectionId": "conn-2", "state": { "unexpected": true } })) .unwrap(); assert_eq!(active_without_state.state, None); let active_string_state: ComposioActiveTrigger = serde_json::from_value(json!({ "id": "trigger-2b", "slug": "SLACK_NEW_MESSAGE", "toolkit": "slack", "connectionId": "conn-2", "state": "READY" })) .unwrap(); assert_eq!(active_string_state.state.as_deref(), Some("READY")); let active_null_state: ComposioActiveTrigger = serde_json::from_value(json!({ "id": "trigger-2c", "slug": "SLACK_NEW_MESSAGE", "toolkit": "slack", "connectionId": "conn-2", "state": null })) .unwrap(); assert_eq!(active_null_state.state, None); let active_numeric_state: ComposioActiveTrigger = serde_json::from_value(json!({ "id": "trigger-2d", "slug": "SLACK_NEW_MESSAGE", "toolkit": "slack", "connectionId": "conn-2", "state": 12 })) .unwrap(); assert_eq!(active_numeric_state.state, None); assert!(serde_json::from_value::(json!({ "id": ["bad"], "slug": "x", "toolkit": "gmail", "connectionId": "c" })) .is_err()); for bad_id in [json!(null), json!(true), json!(123)] { assert!(serde_json::from_value::(json!({ "id": bad_id, "slug": "x", "toolkit": "gmail", "connectionId": "c" })) .is_err()); } let missing_nested_slug = serde_json::from_value::(json!({ "id": "trigger-3", "slug": { "unexpected": true }, "toolkit": "gmail", "connectionId": "c" })) .expect_err("nested slug object needs a known string key"); assert!(missing_nested_slug.to_string().contains("slug/id/name/key")); let enable = ComposioEnableTriggerResponse { trigger_id: "trig-2".into(), slug: "SLACK_NEW_MESSAGE".into(), connection_id: "conn-2".into(), }; assert_eq!( serde_json::to_value(&enable).unwrap()["connectionId"], "conn-2" ); assert!( serde_json::to_value(ComposioDisableTriggerResponse { deleted: false }) .unwrap() .get("deleted") .is_some() ); assert_eq!( serde_json::to_value(ComposioDeleteResponse { deleted: true, memory_chunks_deleted: 4, }) .unwrap()["memory_chunks_deleted"], 4 ); let event: ComposioTriggerEvent = serde_json::from_value(json!({ "toolkit": "gmail", "trigger": "GMAIL_NEW_GMAIL_MESSAGE", "payload": { "subject": "coverage" }, "metadata": { "id": "m1", "uuid": "u1" } })) .unwrap(); assert_eq!(event.metadata.id, "m1"); assert_eq!(event.payload["subject"], "coverage"); let default_event: ComposioTriggerEvent = serde_json::from_value(json!({})).unwrap(); assert_eq!(default_event.metadata.uuid, ""); let metadata = ComposioTriggerMetadata { id: "m2".into(), uuid: "u2".into(), }; assert_eq!(serde_json::to_value(metadata).unwrap()["uuid"], "u2"); let entry = ComposioTriggerHistoryEntry { received_at_ms: 42, toolkit: "gmail".into(), trigger: "GMAIL_NEW_GMAIL_MESSAGE".into(), metadata_id: "m1".into(), metadata_uuid: "u1".into(), payload: json!({ "subject": "coverage" }), }; assert_eq!(serde_json::to_value(entry).unwrap()["received_at_ms"], 42); let history = ComposioTriggerHistoryResult { archive_dir: "/tmp/archive".into(), current_day_file: "/tmp/archive/2026-05-29.jsonl".into(), entries: vec![ComposioTriggerHistoryEntry { received_at_ms: 43, toolkit: "slack".into(), trigger: "SLACK_NEW_MESSAGE".into(), metadata_id: "m2".into(), metadata_uuid: "u2".into(), payload: json!({ "text": "coverage" }), }], }; let history_json = serde_json::to_value(history).unwrap(); assert_eq!(history_json["entries"][0]["metadata_uuid"], "u2"); } #[test] fn composio_direct_public_types_deserialize_polymorphic_toolkits() { let action: ComposioAction = serde_json::from_value(json!({ "name": "GMAIL_SEND_EMAIL", "appName": "gmail", "description": "Send email" })) .unwrap(); assert_eq!(action.name, "GMAIL_SEND_EMAIL"); assert_eq!(action.app_name.as_deref(), Some("gmail")); assert!(!action.enabled); assert_eq!( serde_json::to_value(&action).unwrap()["appName"], json!("gmail") ); let plain: ComposioConnectedAccount = serde_json::from_value(json!({ "id": "acct-1", "status": "ACTIVE", "createdAt": "2026-05-29T00:00:00Z", "toolkit": " gmail " })) .unwrap(); assert_eq!(plain.toolkit_slug().as_deref(), Some("gmail")); assert_eq!(plain.created_at.as_deref(), Some("2026-05-29T00:00:00Z")); let nested: ComposioConnectedAccount = serde_json::from_value(json!({ "id": "acct-2", "toolkit": { "key": "slack" } })) .unwrap(); assert_eq!(nested.toolkit_slug().as_deref(), Some("slack")); for (field, expected) in [ ("slug", "github"), ("id", "googlecalendar"), ("name", "googledrive"), ] { let account: ComposioConnectedAccount = serde_json::from_value(json!({ "id": format!("acct-{field}"), "toolkit": { field: format!(" {expected} ") } })) .unwrap(); assert_eq!(account.toolkit_slug().as_deref(), Some(expected)); } let fallback: ComposioConnectedAccount = serde_json::from_value(json!({ "id": "acct-3", "toolkit": { "ignored": "value" }, "app_name": " notion " })) .unwrap(); assert_eq!(fallback.toolkit_slug().as_deref(), Some("notion")); let missing: ComposioConnectedAccount = serde_json::from_value(json!({ "id": "acct-4", "toolkit": ["bad"], "appName": " " })) .unwrap(); assert_eq!(missing.toolkit_slug(), None); } #[tokio::test] async fn composio_backend_client_methods_build_requests_and_parse_local_envelopes() { let app = Router::new().fallback(any(composio_round8_backend_handler)); let base = start_composio_round8_backend(app).await; let client = ComposioClient::new(Arc::new(IntegrationClient::new( base, "round8-token".into(), ))); let toolkits = client.list_toolkits().await.expect("toolkits"); assert_eq!(toolkits.toolkits, vec!["gmail", "github", "slack"]); let connections = client.list_connections().await.expect("connections"); assert_eq!(connections.connections.len(), 2); assert_eq!(connections.connections[0].normalized_toolkit(), "gmail"); let authorize = client .authorize( " Gmail ", Some(json!({ "waba_id": "waba-1", "oauth_scopes": "profile https://www.googleapis.com/auth/gmail.readonly" })), ) .await .expect("authorize with string scopes"); assert_eq!(authorize.connect_url, "https://connect.example/gmail"); assert_eq!(authorize.connection_id, "conn-gmail"); let authorize_array = client .authorize("gmail", Some(json!({ "oauth_scopes": ["profile", ""] }))) .await .expect("authorize appends missing array scopes"); assert_eq!(authorize_array.connection_id, "conn-gmail"); let bad_scope_entry = client .authorize("gmail", Some(json!({ "oauth_scopes": [42] }))) .await .expect_err("scope entries must be strings"); assert!(bad_scope_entry .to_string() .contains("entries must be strings")); let bad_scope_shape = client .authorize("gmail", Some(json!({ "oauth_scopes": { "bad": true } }))) .await .expect_err("scope shape must be string or array"); assert!(bad_scope_shape .to_string() .contains("must be a string or array")); let tools = client .list_tools( Some(&[ " gmail ".to_string(), "".to_string(), "github/repo".to_string(), ]), Some(&[" important tag ".to_string(), " ".to_string()]), ) .await .expect("tools"); assert_eq!(tools.tools[0].function.name, "GMAIL_SEND_EMAIL"); let all_tools = client.list_tools(None, None).await.expect("all tools"); assert_eq!(all_tools.tools.len(), 1); let execute = client .execute_tool( " GMAIL_SEND_EMAIL ", Some(json!({ "to": "p@example.test" })), ) .await .expect("execute"); assert!(execute.successful); assert_eq!(execute.data["id"], "msg-1"); let dispatched = execute_composio_action( &client, "GMAIL_SEND_EMAIL", Some(json!({ "to": "p@example.test", "subject": "hello" })), ) .await .expect("dispatch uses auth-retry wrapper and local backend"); assert!(dispatched.successful); assert_eq!(dispatched.data["id"], "msg-1"); let execute_error = client .execute_tool( "GMAIL_FETCH_EMAILS", Some(json!({ "query": "newer_than:1d" })), ) .await .expect("execute provider failure envelope"); assert!(!execute_error.successful); assert!(execute_error .error .as_deref() .unwrap_or_default() .starts_with("[composio:error:insufficient_scope]")); let dispatched_error = execute_composio_action( &client, "GMAIL_FETCH_EMAILS", Some(json!({ "query": "newer_than:1d" })), ) .await .expect("provider failures stay in response envelope"); assert!(!dispatched_error.successful); assert!(dispatched_error .error .as_deref() .unwrap_or_default() .starts_with("[composio:error:insufficient_scope]")); let repos = client .list_github_repos(Some(" github conn ")) .await .expect("repos"); assert_eq!(repos.repositories[0].full_name, "tinyhumansai/openhuman"); let repos_without_connection = client .list_github_repos(None) .await .expect("repos without connection"); assert_eq!(repos_without_connection.connection_id, "conn-github"); let created = client .create_trigger( " GITHUB_PULL_REQUEST_EVENT ", Some(" conn-github "), Some(json!({ "owner": "tinyhumansai", "repo": "openhuman" })), ) .await .expect("create trigger"); assert_eq!(created.trigger_id, "created-trigger"); let created_without_config = client .create_trigger("SLACK_NEW_MESSAGE", None, None) .await .expect("create trigger without optional fields"); assert_eq!(created_without_config.status.as_deref(), Some("enabled")); let available = client .list_available_triggers(" github ", Some(" conn-github ")) .await .expect("available triggers"); assert_eq!(available.triggers[0].slug, "GITHUB_PULL_REQUEST_EVENT"); let available_without_connection = client .list_available_triggers("gmail", None) .await .expect("available triggers without connection"); assert_eq!(available_without_connection.triggers[0].scope, "mailbox"); let active = client .list_active_triggers(Some(" gmail ")) .await .expect("active triggers"); assert_eq!(active.triggers[0].toolkit, "gmail"); let active_all = client .list_active_triggers(None) .await .expect("all active triggers"); assert_eq!(active_all.triggers[0].id, "active-trigger"); let enabled = client .enable_trigger( " conn-gmail ", " GMAIL_NEW_GMAIL_MESSAGE ", Some(json!({ "label": "INBOX" })), ) .await .expect("enable trigger"); assert_eq!(enabled.connection_id, "conn-gmail"); let enabled_without_config = client .enable_trigger("conn-gmail", "GMAIL_NEW_GMAIL_MESSAGE", None) .await .expect("enable trigger without config"); assert_eq!(enabled_without_config.slug, "GMAIL_NEW_GMAIL_MESSAGE"); let deleted = client .delete_connection(" conn-gmail ") .await .expect("delete connection"); assert!(deleted.deleted); let disabled = client .disable_trigger(" trigger/with space ") .await .expect("disable trigger"); assert!(disabled.deleted); let delete_status = client .delete_connection("bad-status") .await .expect_err("delete non-2xx"); assert!(delete_status.to_string().contains("Backend returned")); assert!(delete_status.to_string().contains("delete rejected")); let delete_envelope = client .delete_connection("bad-envelope") .await .expect_err("delete envelope error"); assert!(delete_envelope .to_string() .contains("Backend error for DELETE")); let delete_no_data = client .disable_trigger("no-data") .await .expect_err("delete success needs data"); assert!(delete_no_data.to_string().contains("success but no data")); } #[tokio::test] async fn composio_authorize_scope_merging_and_meta_cleanup_use_local_backend() { #[derive(Clone, Default)] struct CleanupState { deleted: Arc>>, authorize_bodies: Arc>>, } async fn handler(State(state): State, request: Request) -> Response { let method = request.method().clone(); let path = request.uri().path().to_string(); let body = to_bytes(request.into_body(), usize::MAX) .await .expect("mock request body"); let body: Value = if body.is_empty() { json!({}) } else { serde_json::from_slice(&body).expect("json request body") }; match (method, path.as_str()) { (Method::GET, "/agent-integrations/composio/connections") => ok(json!({ "connections": [ { "id": "ig-active", "toolkit": "instagram", "status": "ACTIVE" }, { "id": "ig-failed", "toolkit": "instagram", "status": "FAILED" }, { "id": "ig-pending", "toolkit": " Instagram ", "status": "pending" }, { "id": "ig-expired", "toolkit": "instagram", "status": "EXPIRED" }, { "id": "fb-pending", "toolkit": "facebook", "status": "PENDING" }, { "id": "gmail-pending", "toolkit": "gmail", "status": "PENDING" } ] })), (Method::DELETE, path) if path.starts_with("/agent-integrations/composio/connections/") => { state .deleted .lock() .expect("deleted ids") .push(path.rsplit('/').next().unwrap_or_default().to_string()); ok(json!({ "deleted": true, "memory_chunks_deleted": 0 })) } (Method::POST, "/agent-integrations/composio/authorize") => { state .authorize_bodies .lock() .expect("authorize bodies") .push(body.clone()); ok(json!({ "connectUrl": format!( "https://connect.example/{}", body.get("toolkit").and_then(Value::as_str).unwrap_or("unknown") ), "connectionId": "conn-authorize" })) } _ => ( StatusCode::NOT_FOUND, Json(json!({ "success": false, "error": format!("unhandled {path}") })), ) .into_response(), } } let state = CleanupState::default(); let app = Router::new() .fallback(any(handler)) .with_state(state.clone()); let base = start_composio_round8_backend(app).await; let client = ComposioClient::new(Arc::new(IntegrationClient::new( base, "round12-token".into(), ))); assert_eq!( clear_non_active_connections(&client, "gmail") .await .expect("non-meta cleanup is a no-op"), 0 ); assert_eq!( clear_non_active_connections(&client, " Instagram ") .await .expect("stale instagram rows are deleted"), 3 ); let mut deleted = state.deleted.lock().expect("deleted ids").clone(); deleted.sort(); assert_eq!(deleted, vec!["ig-expired", "ig-failed", "ig-pending"]); client .authorize("gmail", Some(json!({ "oauth_scopes": null }))) .await .expect("null scopes are replaced with required gmail scopes"); client .authorize("gmail", None) .await .expect("missing scopes get required gmail scope"); client .authorize("slack", Some(json!({ "bot_scope": "chat:write" }))) .await .expect("non-gmail authorize passes through extra params"); let bodies = state.authorize_bodies.lock().expect("authorize bodies"); assert_eq!( bodies[0]["oauth_scopes"], json!(["https://www.googleapis.com/auth/gmail.readonly"]) ); assert_eq!( bodies[1]["oauth_scopes"], json!(["https://www.googleapis.com/auth/gmail.readonly"]) ); assert!(bodies[2].get("oauth_scopes").is_none()); assert_eq!(bodies[2]["bot_scope"], "chat:write"); } #[tokio::test] async fn composio_direct_tool_public_surface_handles_local_metadata_and_errors() { let tool = ComposioTool::new( " direct-api-key ", Some(" entity-123 "), Arc::new(SecurityPolicy::default()), ); assert_eq!(tool.name(), "composio"); assert!(tool.description().contains("1000+ apps")); assert_eq!(tool.category(), ToolCategory::Workflow); assert!(tool.external_effect()); assert!(!tool.external_effect_with_args(&json!({ "action": "list" }))); assert!(!tool.external_effect_with_args(&json!({ "action": "connect" }))); assert!(tool.external_effect_with_args(&json!({ "action": "execute" }))); assert!(tool.external_effect_with_args(&json!({}))); assert_eq!( tool.parameters_schema().pointer("/required/0"), Some(&json!("action")) ); assert_eq!( tool.parameters_schema() .pointer("/properties/action/enum/2"), Some(&json!("connect")) ); let missing_action = tool .execute(json!({})) .await .expect_err("missing action is a local validation error"); assert!(missing_action.to_string().contains("Missing 'action'")); let unknown = tool .execute(json!({ "action": "inspect" })) .await .expect("unknown action is rendered as a tool error"); assert!(unknown.is_error); assert!(serde_json::to_string(&unknown) .unwrap() .contains("Unknown action 'inspect'")); let missing_execute_name = tool .execute(json!({ "action": "execute", "params": { "q": "test" } })) .await .expect_err("execute needs action_name or tool_slug before network"); assert!(missing_execute_name .to_string() .contains("Missing 'action_name'")); let missing_connect_target = tool .execute(json!({ "action": "connect" })) .await .expect_err("connect needs app or auth config before network"); assert!(missing_connect_target .to_string() .contains("Missing 'app' or 'auth_config_id'")); let read_only_tool = ComposioTool::new( "direct-api-key", Some("default"), Arc::new(SecurityPolicy { autonomy: AutonomyLevel::ReadOnly, ..SecurityPolicy::default() }), ); let blocked_execute = read_only_tool .execute(json!({ "action": "execute", "tool_slug": "GMAIL_FETCH_EMAILS", "params": { "query": "newer_than:1d" } })) .await .expect("policy block is rendered as a tool result"); assert!(blocked_execute.is_error); assert!(serde_json::to_string(&blocked_execute) .unwrap() .contains("read-only mode")); let blocked_connect = read_only_tool .execute(json!({ "action": "connect", "auth_config_id": "auth-config-1" })) .await .expect("policy block is rendered as a tool result"); assert!(blocked_connect.is_error); assert!(serde_json::to_string(&blocked_connect) .unwrap() .contains("read-only mode")); } #[test] fn composio_trigger_history_store_handles_limits_and_bad_archive_lines() { let dir = tempdir().expect("tempdir"); let store = ComposioTriggerHistoryStore::new(dir.path()).expect("history store"); let empty = store.list_recent(0).expect("empty history"); assert!(empty.entries.is_empty()); assert!(empty.archive_dir.ends_with("state/triggers")); let first = store .record_trigger( "gmail", "GMAIL_NEW_GMAIL_MESSAGE", "metadata-1", "uuid-1", &json!({ "subject": "first" }), ) .expect("record first"); assert_eq!(first.toolkit, "gmail"); let second = store .record_trigger( "slack", "SLACK_NEW_MESSAGE", "metadata-2", "uuid-2", &json!({ "text": "second" }), ) .expect("record second"); assert!(second.received_at_ms >= first.received_at_ms); let one = store.list_recent(1).expect("limited history"); assert_eq!(one.entries.len(), 1); assert_eq!(one.entries[0].metadata_id, "metadata-2"); std::fs::write( dir.path() .join("state") .join("triggers") .join("1999-01-01.jsonl"), "\nnot-json\n{\"received_at_ms\":1,\"toolkit\":\"old\",\"trigger\":\"OLD\",\"metadata_id\":\"m\",\"metadata_uuid\":\"u\",\"payload\":{}}\n", ) .expect("write legacy archive"); let all = store.list_recent(10).expect("history skips bad lines"); assert!(all.entries.iter().any(|entry| entry.toolkit == "old")); assert!(all .entries .iter() .any(|entry| entry.metadata_id == "metadata-1")); } async fn start_composio_round8_backend(app: Router) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind mock composio backend"); let addr = listener.local_addr().expect("mock backend addr"); tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); format!("http://127.0.0.1:{}", addr.port()) } fn store_app_session_token(config: &Config, token: &str) { AuthService::from_config(config) .store_provider_token( APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, token, std::collections::HashMap::new(), true, ) .expect("store app session token"); } async fn composio_round8_backend_handler(request: Request) -> Response { let method = request.method().clone(); let uri = request.uri().clone(); let path = uri.path().to_string(); let query = uri.query().unwrap_or_default().to_string(); let body = to_bytes(request.into_body(), usize::MAX) .await .expect("mock request body"); let body: Value = if body.is_empty() { json!({}) } else { serde_json::from_slice(&body).expect("json request body") }; match (method, path.as_str()) { (Method::GET, "/agent-integrations/composio/toolkits") => ok(json!({ "toolkits": ["gmail", "github", "slack"] })), (Method::GET, "/agent-integrations/composio/connections") => ok(json!({ "connections": [ { "id": "conn-gmail", "toolkit": " Gmail ", "status": "ACTIVE", "createdAt": "2026-05-29T00:00:00Z" }, { "id": "conn-slack", "toolkit": "slack", "status": "PENDING" } ] })), (Method::POST, "/agent-integrations/composio/authorize") => { let toolkit = body .get("toolkit") .and_then(Value::as_str) .expect("authorize toolkit"); assert_eq!(toolkit.to_ascii_lowercase(), "gmail"); let scopes = body .get("oauth_scopes") .and_then(Value::as_array) .expect("oauth scopes array"); assert!(scopes .iter() .any(|scope| scope == "https://www.googleapis.com/auth/gmail.readonly")); ok(json!({ "connectUrl": "https://connect.example/gmail", "connectionId": "conn-gmail" })) } (Method::GET, "/agent-integrations/composio/tools") => { if !query.is_empty() { assert!(query.contains("toolkits=gmail,github%2Frepo")); assert!(query.contains("tags=important%20tag")); } ok(json!({ "tools": [{ "type": "function", "function": { "name": "GMAIL_SEND_EMAIL", "description": "Send mail", "parameters": { "type": "object" } } }] })) } (Method::POST, "/agent-integrations/composio/execute") => { match body.get("tool").and_then(Value::as_str) { Some("GMAIL_FETCH_EMAILS") => ok(json!({ "data": null, "successful": false, "error": "403 insufficient authentication scopes", "costUsd": 0.0 })), _ => ok(json!({ "data": { "id": "msg-1" }, "successful": true, "error": null, "costUsd": 0.01, "markdownFormatted": "**sent**" })), } } (Method::GET, "/agent-integrations/composio/github/repos") => ok(json!({ "connectionId": "conn-github", "repositories": [{ "owner": "tinyhumansai", "repo": "openhuman", "fullName": "tinyhumansai/openhuman", "private": false, "defaultBranch": "main", "htmlUrl": "https://github.com/tinyhumansai/openhuman" }] })), (Method::POST, "/agent-integrations/composio/triggers") => { if body.get("slug").and_then(Value::as_str) == Some("GITHUB_PULL_REQUEST_EVENT") { assert_eq!(body["connectionId"], "conn-github"); ok(json!({ "triggerId": "created-trigger", "status": "enabled" })) } else if body.get("connectionId").is_some() { assert_eq!(body["connectionId"], "conn-gmail"); ok(json!({ "triggerId": "enabled-trigger", "slug": "GMAIL_NEW_GMAIL_MESSAGE", "connectionId": "conn-gmail" })) } else { ok(json!({ "triggerId": "created-trigger", "status": "enabled" })) } } (Method::GET, "/agent-integrations/composio/triggers/available") => { if query.contains("github") { assert!(query.contains("connectionId=conn-github")); ok(json!({ "triggers": [{ "slug": "GITHUB_PULL_REQUEST_EVENT", "scope": "github_repo", "defaultConfig": { "event": "pull_request" }, "requiredConfigKeys": ["owner", "repo"], "repo": { "owner": "tinyhumansai", "repo": "openhuman" } }] })) } else { ok(json!({ "triggers": [{ "slug": "GMAIL_NEW_GMAIL_MESSAGE", "scope": "mailbox" }] })) } } (Method::GET, "/agent-integrations/composio/triggers") => ok(json!({ "triggers": [{ "id": "active-trigger", "slug": "GMAIL_NEW_GMAIL_MESSAGE", "toolkit": "gmail", "connectionId": "conn-gmail", "triggerConfig": { "label": "INBOX" }, "state": "enabled" }] })), (Method::DELETE, path) if path.starts_with("/agent-integrations/composio/connections/") => { let id = path.rsplit('/').next().unwrap_or_default(); match id { "bad-status" => ( StatusCode::BAD_REQUEST, Json(json!({ "success": false, "error": "delete rejected" })), ) .into_response(), "bad-envelope" => Json(json!({ "success": false, "error": "delete envelope rejected" })) .into_response(), _ => ok(json!({ "deleted": true, "memory_chunks_deleted": 2 })), } } (Method::DELETE, path) if path.starts_with("/agent-integrations/composio/triggers/") => { let id = path.rsplit('/').next().unwrap_or_default(); if id == "no-data" { Json(json!({ "success": true })).into_response() } else { ok(json!({ "deleted": true })) } } _ => ( StatusCode::NOT_FOUND, Json(json!({ "success": false, "error": format!("unhandled {path}") })), ) .into_response(), } } fn ok(data: Value) -> Response { Json(json!({ "success": true, "data": data })).into_response() }