diff --git a/src/openhuman/composio/error_mapping.rs b/src/openhuman/composio/error_mapping.rs index f12e3ba2c..1f13a5d70 100644 --- a/src/openhuman/composio/error_mapping.rs +++ b/src/openhuman/composio/error_mapping.rs @@ -14,6 +14,13 @@ pub enum ComposioErrorClass { /// matches it). Distinct so the user gets reconnect guidance. See #2913. TriggerPermission, RateLimited, + /// Composio answered the execute call with an HTTP 404/410 — the action + /// endpoint or slug is unknown (or the endpoint is deprecated/Gone), NOT a + /// broken connection. Distinct so the user is told their integration is + /// still connected and is **not** advised to re-authenticate a healthy + /// account. See #3219 (direct-mode sent the lowercase toolkit slug to the + /// wrong execute path → 404/410 → misclassified as [`Self::ComposioPlatform`]). + ActionNotFound, UpstreamProvider, ComposioPlatform, Gateway, @@ -27,6 +34,7 @@ impl ComposioErrorClass { Self::InsufficientScope => "insufficient_scope", Self::TriggerPermission => "trigger_permission", Self::RateLimited => "rate_limited", + Self::ActionNotFound => "action_not_found", Self::UpstreamProvider => "upstream_provider", Self::ComposioPlatform => "composio_platform", Self::Gateway => "gateway", @@ -37,7 +45,15 @@ impl ComposioErrorClass { pub fn classify_composio_error(tool: &str, message: &str) -> ComposioErrorClass { let lower = message.to_ascii_lowercase(); - let class = if is_validation_shape(&lower) { + // EARLY status arm (#3219): a Composio HTTP 404/410 means the action + // endpoint/slug is unknown or deprecated — NOT a broken connection. It must + // win over the substring arms below, because the 404 body frequently carries + // the phrase "connection error, try to authenticate" that + // `is_composio_platform_shape` would otherwise match, telling the user to + // re-authenticate a perfectly healthy connection. + let class = if is_action_not_found_shape(&lower) { + ComposioErrorClass::ActionNotFound + } else if is_validation_shape(&lower) { ComposioErrorClass::Validation } else if is_insufficient_scope_shape(&lower) { ComposioErrorClass::InsufficientScope @@ -74,6 +90,7 @@ pub fn format_provider_error(tool: &str, raw: &str) -> String { ComposioErrorClass::InsufficientScope => format_insufficient_scope_message(tool, detail), ComposioErrorClass::TriggerPermission => format_trigger_permission_message(tool), ComposioErrorClass::RateLimited => format_rate_limited_message(tool, detail), + ComposioErrorClass::ActionNotFound => format_action_not_found_message(tool), ComposioErrorClass::UpstreamProvider => { format!("`{tool}` failed at the connected provider: {detail}") } @@ -101,6 +118,7 @@ pub fn remap_transport_error(tool: &str, raw: &str) -> String { ComposioErrorClass::InsufficientScope => format_insufficient_scope_message(tool, &detail), ComposioErrorClass::TriggerPermission => format_trigger_permission_message(tool), ComposioErrorClass::RateLimited => format_rate_limited_message(tool, &detail), + ComposioErrorClass::ActionNotFound => format_action_not_found_message(tool), ComposioErrorClass::Gateway => format!( "Temporary gateway error while calling `{tool}`: {}", summarize_gateway(raw) @@ -157,6 +175,23 @@ fn format_trigger_permission_message(tool: &str) -> String { ) } +/// Build the action-not-found guidance (issue #3219). +/// +/// Deliberately does **not** echo the raw provider `detail`: a Composio 404 +/// body often literally reads "connection error, try to authenticate", and +/// surfacing that verbatim would re-introduce the exact misleading re-auth +/// nudge this class exists to suppress. The raw text is still captured in the +/// `tracing::debug!` line in [`classify_composio_error`] for diagnosis. +fn format_action_not_found_message(tool: &str) -> String { + let toolkit = derive_toolkit_slug(tool); + format!( + "`{tool}` couldn't run: Composio reported this action as not found. Your {toolkit} \ + integration is still connected and working — this is not a sign-in problem. The action \ + name is likely out of date or Composio's API changed; try again with the current action \ + name, or report this if it keeps happening." + ) +} + fn format_rate_limited_message(tool: &str, detail: &str) -> String { format!( "`{tool}` hit an upstream rate limit ({detail}). Wait a minute and retry, or reduce \ @@ -210,6 +245,17 @@ fn is_rate_limited_shape(lower: &str) -> bool { || lower.contains("429") } +/// True when the message carries a Composio HTTP 404 or 410 status (#3219). +/// +/// Both `response_error` shapes (`HTTP 404` and `HTTP 404: `) and the +/// wrapped v3/v2 fallback string keep the literal `HTTP ` token, so a +/// substring scan is reliable. Scoped to 404 (action/endpoint unknown) and 410 +/// (endpoint Gone/deprecated) on purpose — other 4xx/5xx keep their existing +/// validation / scope / rate-limit / gateway classifications. +fn is_action_not_found_shape(lower: &str) -> bool { + lower.contains("http 404") || lower.contains("http 410") +} + fn is_composio_platform_shape(lower: &str) -> bool { lower.contains("connection error, try to authenticate") || lower.contains("not enabled") @@ -232,6 +278,7 @@ fn is_embedded_provider_failure(lower: &str) -> bool { || is_insufficient_scope_shape(lower) || is_trigger_permission_shape(lower) || is_rate_limited_shape(lower) + || is_action_not_found_shape(lower) || is_composio_platform_shape(lower) || lower.contains("composio") || lower.contains("google") diff --git a/src/openhuman/composio/error_mapping_tests.rs b/src/openhuman/composio/error_mapping_tests.rs index 26469cd82..6a9fbd98b 100644 --- a/src/openhuman/composio/error_mapping_tests.rs +++ b/src/openhuman/composio/error_mapping_tests.rs @@ -80,6 +80,81 @@ fn true_gateway_stays_gateway_class() { ); } +// ── HTTP 404/410 action-not-found vs auth (issue #3219) ──────────────── + +#[test] +fn classifies_http_404_with_auth_phrase_as_action_not_found_not_platform() { + // The exact #3219 shape: a 404 whose body carries the misleading + // "connection error, try to authenticate" phrase. Status must win. + let msg = "HTTP 404: connection error, try to authenticate"; + assert_eq!( + classify_composio_error("GMAIL_SEND_EMAIL", msg), + ComposioErrorClass::ActionNotFound + ); +} + +#[test] +fn classifies_http_410_gone_as_action_not_found() { + let msg = "HTTP 410: This endpoint is deprecated"; + assert_eq!( + classify_composio_error("GOOGLEDOCS_UPDATE_DOCUMENT", msg), + ComposioErrorClass::ActionNotFound + ); +} + +#[test] +fn action_not_found_message_does_not_recommend_reauth() { + let mapped = format_provider_error( + "GMAIL_SEND_EMAIL", + "HTTP 404: connection error, try to authenticate", + ); + let lower = mapped.to_lowercase(); + assert!(mapped.contains("[composio:error:action_not_found]")); + assert!(lower.contains("still connected")); + // Must NOT echo the misleading provider phrase or nudge re-auth/reconnect. + assert!( + !lower.contains("authenticate"), + "must not surface the re-auth phrase: {mapped}" + ); + assert!( + !lower.contains("reconnect"), + "must not tell the user to reconnect a healthy connection: {mapped}" + ); + assert!( + !mapped.contains("Settings → Connections"), + "must not show the re-auth CTA: {mapped}" + ); +} + +#[test] +fn genuine_auth_phrase_without_http_status_stays_platform() { + // Same phrase, but with NO 4xx status — a real platform/connection issue + // must still classify as ComposioPlatform. + let msg = "connection error, try to authenticate"; + assert_eq!( + classify_composio_error("GMAIL_SEND_EMAIL", msg), + ComposioErrorClass::ComposioPlatform + ); +} + +#[test] +fn wrapped_v3_v2_404_fallback_classifies_as_action_not_found() { + // The real transport string after both v3 and v2 fail — the wrapped form + // produced by `execute_action`, fed through `remap_transport_error`. + let raw = "Composio execute failed on v3 (Composio v3 action execution failed: \ + HTTP 404: connection error, try to authenticate) and v2 fallback \ + (Composio v2 action execution failed: HTTP 410: Gone)"; + let mapped = remap_transport_error("GMAIL_SEND_EMAIL", raw); + assert!( + mapped.contains("[composio:error:action_not_found]"), + "wrapped 404/410 must map to action_not_found, got: {mapped}" + ); + assert!( + !mapped.to_lowercase().contains("authenticate"), + "wrapped path must not surface the re-auth phrase: {mapped}" + ); +} + // ── Trigger-permission denial (issue #2913) ─────────────────────────── #[test] diff --git a/src/openhuman/composio/tools/direct.rs b/src/openhuman/composio/tools/direct.rs index 96d924dc3..287524193 100644 --- a/src/openhuman/composio/tools/direct.rs +++ b/src/openhuman/composio/tools/direct.rs @@ -362,10 +362,22 @@ impl ComposioTool { entity_id: Option<&str>, connected_account_ref: Option<&str>, ) -> anyhow::Result { - let tool_slug = normalize_tool_slug(action_name); + // The Composio v3 action-execute contract keys off the UPPERCASE_SNAKE + // *action* slug (e.g. `GMAIL_SEND_EMAIL`) at `/tools/execute/{slug}`. + // The previous code lowercased + dashed it into the *toolkit* slug + // (`gmail-send-email`) and posted to the wrong `/tools/{slug}/execute` + // path, so every direct-mode execute 404'd (issue #3219). Pass the + // action slug through verbatim (trimmed only); the v2 fallback already + // used the same untransformed name. + let action_slug = action_name.trim(); match self - .execute_action_v3(&tool_slug, params.clone(), entity_id, connected_account_ref) + .execute_action_v3( + action_slug, + params.clone(), + entity_id, + connected_account_ref, + ) .await { Ok(result) => Ok(result), @@ -379,12 +391,15 @@ impl ComposioTool { } fn build_execute_action_v3_request( - tool_slug: &str, + action_slug: &str, params: serde_json::Value, entity_id: Option<&str>, connected_account_ref: Option<&str>, ) -> (String, serde_json::Value) { - let url = format!("{COMPOSIO_API_BASE_V3}/tools/{tool_slug}/execute"); + // POST /api/v3/tools/execute/{ACTION_SLUG} — the action slug stays + // UPPERCASE_SNAKE (see `execute_action`). Path is `/tools/execute/{slug}`, + // NOT `/tools/{slug}/execute` (issue #3219). + let url = format!("{COMPOSIO_API_BASE_V3}/tools/execute/{action_slug}"); let account_ref = connected_account_ref.and_then(|candidate| { let trimmed_candidate = candidate.trim(); (!trimmed_candidate.is_empty()).then_some(trimmed_candidate) @@ -406,18 +421,18 @@ impl ComposioTool { async fn execute_action_v3( &self, - tool_slug: &str, + action_slug: &str, params: serde_json::Value, entity_id: Option<&str>, connected_account_ref: Option<&str>, ) -> anyhow::Result { let (_default_url, body) = Self::build_execute_action_v3_request( - tool_slug, + action_slug, params, entity_id, connected_account_ref, ); - let url = format!("{}/tools/{tool_slug}/execute", self.base_v3); + let url = format!("{}/tools/execute/{action_slug}", self.base_v3); self.ensure_request_url(&url)?; @@ -885,10 +900,6 @@ fn normalize_entity_id(entity_id: &str) -> String { } } -fn normalize_tool_slug(action_name: &str) -> String { - action_name.trim().replace('_', "-").to_ascii_lowercase() -} - fn map_v3_tools_to_actions(items: Vec) -> Vec { items .into_iter() diff --git a/src/openhuman/composio/tools/direct_tests.rs b/src/openhuman/composio/tools/direct_tests.rs index 83284dff7..aa83a3d0e 100644 --- a/src/openhuman/composio/tools/direct_tests.rs +++ b/src/openhuman/composio/tools/direct_tests.rs @@ -184,18 +184,6 @@ fn normalize_entity_id_falls_back_to_default_when_blank() { assert_eq!(normalize_entity_id("workspace-user"), "workspace-user"); } -#[test] -fn normalize_tool_slug_supports_legacy_action_name() { - assert_eq!( - normalize_tool_slug("GMAIL_FETCH_EMAILS"), - "gmail-fetch-emails" - ); - assert_eq!( - normalize_tool_slug(" github-list-repos "), - "github-list-repos" - ); -} - #[test] fn extract_redirect_url_supports_v2_and_v3_shapes() { let v2 = json!({"redirectUrl": "https://app.composio.dev/connect-v2"}); @@ -313,19 +301,24 @@ fn composio_api_base_url_is_v3() { } #[test] -fn build_execute_action_v3_request_uses_fixed_endpoint_and_body_account_id() { +fn build_execute_action_v3_request_uses_execute_path_and_uppercase_action_slug() { + // #3219: v3 action execute is POST /tools/execute/{ACTION_SLUG} with the + // UPPERCASE_SNAKE action slug — NOT /tools/{lowercase-dashed}/execute. let (url, body) = ComposioTool::build_execute_action_v3_request( - "gmail-send-email", - json!({"to": "test@example.com"}), + "GMAIL_SEND_EMAIL", + json!({"recipient_email": "test@example.com"}), Some("workspace-user"), Some("account-42"), ); assert_eq!( url, - "https://backend.composio.dev/api/v3/tools/gmail-send-email/execute" + "https://backend.composio.dev/api/v3/tools/execute/GMAIL_SEND_EMAIL" + ); + assert_eq!( + body["arguments"]["recipient_email"], + json!("test@example.com") ); - assert_eq!(body["arguments"]["to"], json!("test@example.com")); assert_eq!(body["user_id"], json!("workspace-user")); assert_eq!(body["connected_account_id"], json!("account-42")); } @@ -333,7 +326,7 @@ fn build_execute_action_v3_request_uses_fixed_endpoint_and_body_account_id() { #[test] fn build_execute_action_v3_request_drops_blank_optional_fields() { let (url, body) = ComposioTool::build_execute_action_v3_request( - "github-list-repos", + "GITHUB_LIST_REPOSITORIES", json!({}), None, Some(" "), @@ -341,7 +334,7 @@ fn build_execute_action_v3_request_drops_blank_optional_fields() { assert_eq!( url, - "https://backend.composio.dev/api/v3/tools/github-list-repos/execute" + "https://backend.composio.dev/api/v3/tools/execute/GITHUB_LIST_REPOSITORIES" ); assert_eq!(body["arguments"], json!({})); assert!(body.get("connected_account_id").is_none()); @@ -488,6 +481,61 @@ async fn list_tool_schemas_v3_sends_repeated_tags_to_v3_tools_endpoint() { assert_eq!(items[0].toolkit_slug.as_deref(), Some("github")); } +// ── execute_action over HTTP (correct v3 path/slug/body reach the wire) ──── + +#[tokio::test] +async fn execute_action_v3_posts_uppercase_slug_to_execute_path() { + use axum::{extract::Path, routing::post, Json, Router}; + use std::sync::Mutex; + + // Capture the path slug + body the server actually received so we assert on + // the WIRE shape, not just the pure builder. Regression guard for #3219. + let captured: Arc>> = Arc::new(Mutex::new(None)); + let sink = captured.clone(); + let app = Router::new().route( + "/tools/execute/{slug}", + post( + move |Path(slug): Path, Json(body): Json| { + let sink = sink.clone(); + async move { + *sink.lock().unwrap() = Some((slug, body)); + Json(json!({ "successful": true, "data": { "id": "msg_1" } })) + } + }, + ), + ); + let base = start_mock_backend(app).await; + + let tool = ComposioTool::new_with_v3_base("ck_test_direct", None, test_security(), base); + let result = tool + .execute_action( + "GMAIL_SEND_EMAIL", + json!({ "recipient_email": "a@b.com" }), + Some("workspace-user"), + Some("ca_42"), + ) + .await + .expect("v3 execute should succeed against the mock"); + + assert_eq!(result["successful"], json!(true)); + + let (slug, body) = captured + .lock() + .unwrap() + .clone() + .expect("mock server should have observed the execute request"); + + // The action slug must reach the URL UPPERCASE_SNAKE — the toolkit-slug + // transform (gmail-send-email) was the root cause of the 404 in #3219. + assert_eq!( + slug, "GMAIL_SEND_EMAIL", + "must POST the uppercase action slug" + ); + assert_eq!(body["arguments"]["recipient_email"], json!("a@b.com")); + assert_eq!(body["user_id"], json!("workspace-user")); + assert_eq!(body["connected_account_id"], json!("ca_42")); +} + // ── ensure_https ────────────────────────────────────────────────────────── #[test] diff --git a/tests/composio_tools_direct_raw_coverage_e2e.rs b/tests/composio_tools_direct_raw_coverage_e2e.rs index 2f70cc8a5..74f77bb36 100644 --- a/tests/composio_tools_direct_raw_coverage_e2e.rs +++ b/tests/composio_tools_direct_raw_coverage_e2e.rs @@ -164,7 +164,7 @@ async fn direct_composio_tool_uses_loopback_for_list_execute_connect_and_account let execute_result = tool .execute(json!({ "action": "execute", - "tool_slug": "gmail-fetch-emails", + "tool_slug": "GMAIL_FETCH_EMAILS", "params": { "query": "newer_than:1d" }, "connected_account_id": "acct-gmail" })) @@ -204,7 +204,7 @@ async fn direct_composio_tool_uses_loopback_for_list_execute_connect_and_account })); assert!(requests.iter().any(|request| { request.method == "POST" - && request.path == "/api/v3/tools/gmail-fetch-emails/execute" + && request.path == "/api/v3/tools/execute/GMAIL_FETCH_EMAILS" && request.body.pointer("/user_id") == Some(&json!(" entity-override ")) && request.body.pointer("/connected_account_id") == Some(&json!("acct-gmail")) })); @@ -319,14 +319,14 @@ async fn composio_direct_handler(State(state): State, request: Reques ] })) .into_response(), - (Method::POST, "/api/v3/tools/gmail-fetch-emails/execute") => Json(json!({ + (Method::POST, "/api/v3/tools/execute/GMAIL_FETCH_EMAILS") => Json(json!({ "successful": true, "data": { "messages": [{ "id": "msg-direct", "subject": "hello" }] } })) .into_response(), - (Method::POST, "/api/v3/tools/fallback-action/execute") => ( + (Method::POST, "/api/v3/tools/execute/FALLBACK_ACTION") => ( StatusCode::BAD_GATEWAY, Json(json!({ "error": { "message": "temporary v3 outage" } @@ -338,7 +338,7 @@ async fn composio_direct_handler(State(state): State, request: Reques "input": body.get("input").cloned().unwrap_or_else(|| json!({})) })) .into_response(), - (Method::POST, "/api/v3/tools/broken-action/execute") => ( + (Method::POST, "/api/v3/tools/execute/BROKEN_ACTION") => ( StatusCode::BAD_REQUEST, Json(json!({ "error": { diff --git a/tests/tools_composio_large_round25_raw_coverage_e2e.rs b/tests/tools_composio_large_round25_raw_coverage_e2e.rs index 83c70c4b9..ff9427785 100644 --- a/tests/tools_composio_large_round25_raw_coverage_e2e.rs +++ b/tests/tools_composio_large_round25_raw_coverage_e2e.rs @@ -282,7 +282,7 @@ async fn round25_direct_mode_ops_use_loopback_factory_for_tools_connections_and_ })); assert!(requests.iter().any(|request| { request.method == Method::POST - && request.path == "/api/v3/tools/gmail-fetch-emails/execute" + && request.path == "/api/v3/tools/execute/GMAIL_FETCH_EMAILS" && request.body["arguments"]["query"] == "label:INBOX" && request.body["user_id"] == "entity-round25" })); @@ -402,7 +402,7 @@ async fn composio_direct_handler(State(state): State, request: Reques } })) .into_response(), - (Method::POST, "/api/v3/tools/gmail-fetch-emails/execute") => Json(json!({ + (Method::POST, "/api/v3/tools/execute/GMAIL_FETCH_EMAILS") => Json(json!({ "successful": true, "data": { "messages": [ @@ -411,7 +411,7 @@ async fn composio_direct_handler(State(state): State, request: Reques } })) .into_response(), - (Method::POST, "/api/v3/tools/gmail-send-email/execute") => Json(json!({ + (Method::POST, "/api/v3/tools/execute/GMAIL_SEND_EMAIL") => Json(json!({ "successful": false, "error": "provider rejected send", "data": { diff --git a/tests/tools_composio_network_leftovers_raw_coverage_e2e.rs b/tests/tools_composio_network_leftovers_raw_coverage_e2e.rs index a4a166e07..9886e7ba5 100644 --- a/tests/tools_composio_network_leftovers_raw_coverage_e2e.rs +++ b/tests/tools_composio_network_leftovers_raw_coverage_e2e.rs @@ -454,7 +454,7 @@ async fn round20_direct_composio_tool_covers_fallback_sanitizing_and_account_edg })); assert!(requests.iter().any(|request| { request.method == Method::POST - && request.path == "/api/v3/tools/gmail-fetch-emails/execute" + && request.path == "/api/v3/tools/execute/GMAIL_FETCH_EMAILS" && request.body.pointer("/connected_account_id") == Some(&json!("acct-gmail")) })); } @@ -780,13 +780,13 @@ async fn composio_direct_handler(State(state): State, request: Reques StatusCode::BAD_REQUEST, "v2 broken list mentions connected_account_id and user_id", ), - (Method::POST, "/api/v3/tools/gmail-fetch-emails/execute") => Json(json!({ + (Method::POST, "/api/v3/tools/execute/GMAIL_FETCH_EMAILS") => Json(json!({ "successful": true, "data": { "messages": [{ "id": "msg-round20" }] }, "error": null })) .into_response(), - (Method::POST, "/api/v3/tools/broken-action/execute") => message_fail( + (Method::POST, "/api/v3/tools/execute/BROKEN_ACTION") => message_fail( StatusCode::BAD_REQUEST, "bad execute connected_account_id user_id entity_id", ), diff --git a/tests/tools_composio_round24_raw_coverage_e2e.rs b/tests/tools_composio_round24_raw_coverage_e2e.rs index 5e1b4632f..c16c75cf7 100644 --- a/tests/tools_composio_round24_raw_coverage_e2e.rs +++ b/tests/tools_composio_round24_raw_coverage_e2e.rs @@ -91,7 +91,7 @@ async fn round24_composio_direct_covers_v3_v2_fallbacks_and_account_shapes() { .iter() .find(|request| { request.method == Method::POST - && request.path == "/api/v3/tools/gmail-send-email/execute" + && request.path == "/api/v3/tools/execute/GMAIL_SEND_EMAIL" }) .expect("v3 execute request"); assert_eq!(v3_execute.body["connected_account_id"], "conn-secret"); @@ -267,7 +267,7 @@ async fn composio_handler(State(state): State, request: Request) -> R ] })) .into_response(), - (Method::POST, "/api/v3/tools/gmail-send-email/execute") => ( + (Method::POST, "/api/v3/tools/execute/GMAIL_SEND_EMAIL") => ( StatusCode::BAD_REQUEST, Json(json!({ "error": { diff --git a/tests/tools_composio_round26_raw_coverage_e2e.rs b/tests/tools_composio_round26_raw_coverage_e2e.rs index 2bc7bf5a8..d6d71d6f4 100644 --- a/tests/tools_composio_round26_raw_coverage_e2e.rs +++ b/tests/tools_composio_round26_raw_coverage_e2e.rs @@ -320,7 +320,7 @@ async fn round26_composio_direct_tool_covers_connect_execute_and_error_fallbacks let v3_execute = requests .iter() .find(|request| { - request.method == Method::POST && request.path == "/api/v3/tools/round26-action/execute" + request.method == Method::POST && request.path == "/api/v3/tools/execute/ROUND26_ACTION" }) .expect("v3 execute request"); assert_eq!(v3_execute.body["user_id"], "entity-round26"); @@ -463,12 +463,12 @@ async fn composio_handler(State(state): State, request: Request) -> R ] })) .into_response(), - (Method::POST, "/api/v3/tools/round26-action/execute") => Json(json!({ + (Method::POST, "/api/v3/tools/execute/ROUND26_ACTION") => Json(json!({ "successful": true, "data": { "message": "v3-execute-round26" } })) .into_response(), - (Method::POST, "/api/v3/tools/round26-v2-only/execute") => ( + (Method::POST, "/api/v3/tools/execute/ROUND26_V2_ONLY") => ( StatusCode::BAD_GATEWAY, Json(json!({ "message": "v3 execute unavailable" })), )