Skip to content
Merged
55 changes: 54 additions & 1 deletion app/src/lib/composio/formatters.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,59 @@
import { describe, expect, it } from 'vitest';

import { formatTriggerLabel } from './formatters';
import { formatComposioToolError, formatTriggerLabel } from './formatters';

describe('formatComposioToolError', () => {
it('strips the classified prefix and returns the body', () => {
const raw =
'[composio:error:insufficient_scope] `GMAIL_FETCH_EMAILS` was rejected because the connected gmail account is missing required permissions.';
expect(formatComposioToolError(raw)).toContain('missing required permissions');
expect(formatComposioToolError(raw)).not.toContain('[composio:error:');
});

it('passes through unclassified messages', () => {
expect(formatComposioToolError('plain failure')).toBe('plain failure');
});

it('returns empty string for null/undefined/empty input', () => {
expect(formatComposioToolError(null)).toBe('');
expect(formatComposioToolError(undefined)).toBe('');
expect(formatComposioToolError('')).toBe('');
});

it('falls back to validation copy when body is empty', () => {
expect(formatComposioToolError('[composio:error:validation]')).toBe('Invalid tool arguments.');
});

it('falls back to insufficient_scope copy when body is empty', () => {
expect(formatComposioToolError('[composio:error:insufficient_scope]')).toBe(
'Reconnect this integration and grant the requested permissions.'
);
});

it('falls back to rate_limited copy when body is empty', () => {
expect(formatComposioToolError('[composio:error:rate_limited]')).toBe(
'The upstream service is rate-limiting requests. Try again shortly.'
);
});

it('falls back to gateway copy when body is empty', () => {
expect(formatComposioToolError('[composio:error:gateway]')).toBe(
'Temporary connection issue. Try again in a moment.'
);
});

it('returns body for unknown class when present', () => {
expect(formatComposioToolError('[composio:error:something_new] details here')).toBe(
'details here'
);
});

it('falls back to trimmed raw for unknown class with empty body', () => {
expect(formatComposioToolError(' [composio:error:something_new] ')).toBe(
'[composio:error:something_new]'
);
});
});

describe('formatTriggerLabel', () => {
it('formats GOOGLECALENDAR_GOOGLE_CALENDAR_EVENT_CREATED_TRIGGER correctly', () => {
Expand Down
23 changes: 23 additions & 0 deletions app/src/lib/composio/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@
* 4. dedupe leading provider prefix when it reappears
* 5. split on _, title-case each token, join with space
*/
/**
* Parse a classified Composio error (`[composio:error:<class>] …`) for UI copy.
*/
export function formatComposioToolError(raw: string | null | undefined): string {
if (!raw) return '';
const match = /^\[composio:error:([a-z_]+)\]\s*(.*)$/is.exec(raw.trim());
if (!match) return raw.trim();

const [, className, body] = match;
switch (className) {
case 'validation':
return body || 'Invalid tool arguments.';
case 'insufficient_scope':
return body || 'Reconnect this integration and grant the requested permissions.';
case 'rate_limited':
return body || 'The upstream service is rate-limiting requests. Try again shortly.';
case 'gateway':
return body || 'Temporary connection issue. Try again in a moment.';
default:
return body || raw.trim();
}
}

export function formatTriggerLabel(
slug: string | null | undefined,
opts?: { overrides?: Record<string, string> }
Expand Down
43 changes: 14 additions & 29 deletions src/openhuman/composio/action_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;

use super::client::{create_composio_client, direct_execute, ComposioClientKind};
use super::client::create_composio_client;
use super::providers::ToolScope;
use super::tools::resolve_action_scope;
use crate::openhuman::agent::harness::current_sandbox_mode;
Expand Down Expand Up @@ -195,33 +195,18 @@ impl Tool for ComposioActionTool {
};

let started = std::time::Instant::now();
let res = match kind {
ComposioClientKind::Backend(client) => {
tracing::debug!(
tool = %self.action_name,
"[composio] per-action execute: backend variant"
);
// Wrap with auth_retry so a stale tinyhumans-tenant
// JWT gets refreshed-and-replayed once before surfacing
// (upstream behaviour).
super::auth_retry::execute_with_auth_retry(&client, &self.action_name, args).await
}
ComposioClientKind::Direct(direct) => {
tracing::debug!(
tool = %self.action_name,
"[composio] per-action execute: direct variant"
);
// Direct path skips auth_retry — see ComposioExecuteTool
// for rationale (no backend refresh surface).
direct_execute(
&direct,
&self.action_name,
args,
&live_config.composio.entity_id,
)
.await
}
};
// Route through the centralized dispatcher (#1797) so both
// backend and direct variants share the same prepare/retry/error-
// mapping pipeline. The dispatcher applies `format_provider_error`
// to failures (transport + provider) so downstream consumers can
// parse `[composio:error:<class>] …`.
let res = super::execute_dispatch::execute_composio_action_kind(
kind,
&self.action_name,
args,
&live_config.composio.entity_id,
)
.await;
let elapsed_ms = started.elapsed().as_millis() as u64;

match res {
Expand Down Expand Up @@ -267,7 +252,7 @@ impl Tool for ComposioActionTool {
elapsed_ms,
},
);
Ok(ToolResult::error(format!("{}: {e}", self.action_name)))
Ok(ToolResult::error(e))
}
}
}
Expand Down
84 changes: 26 additions & 58 deletions src/openhuman/composio/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ impl ComposioClient {
if tool.is_empty() {
anyhow::bail!("composio.execute_tool_once: tool slug must not be empty");
}
let arguments = arguments.unwrap_or(serde_json::Value::Object(Default::default()));
let arguments = super::execute_prepare::prepare_execute_arguments(tool, arguments)
.map_err(anyhow::Error::msg)?;
tracing::debug!(tool = %tool, "[composio] execute_tool_once (no built-in retry)");
let body = json!({ "tool": tool, "arguments": arguments });
let result = self.post_execute_tool(&body).await;
Expand All @@ -183,7 +184,12 @@ impl ComposioClient {
"[composio] execute_tool_once failed"
),
}
result
result.map_err(|e| {
anyhow::Error::msg(super::error_mapping::remap_transport_error(
tool,
&e.to_string(),
))
})
}

/// `POST /agent-integrations/composio/execute` — run a Composio
Expand All @@ -197,12 +203,26 @@ impl ComposioClient {
if tool.is_empty() {
anyhow::bail!("composio.execute_tool: tool slug must not be empty");
}
let mut arguments = arguments.unwrap_or(serde_json::Value::Object(Default::default()));
normalize_calendar_query_args(tool, &mut arguments);
// PR #1827 routes all execute-side argument normalization
// (including the bare-date → RFC 3339 fix #1802 brought to
// `normalize_calendar_query_args` on `main`) through the
// centralized `prepare_execute_arguments` helper. The helper
// covers the same calendar query case and is the shared entry
// point for `composio_execute`, per-action tools, and direct-
// mode dispatch.
let arguments = super::execute_prepare::prepare_execute_arguments(tool, arguments)
.map_err(anyhow::Error::msg)?;
tracing::debug!(tool = %tool, "[composio] execute_tool");
let body = json!({ "tool": tool, "arguments": arguments });
self.execute_tool_with_post_oauth_retry(tool, &body, POST_OAUTH_ACTION_RETRY_DELAY)
.await
let mut resp = self
.execute_tool_with_post_oauth_retry(tool, &body, POST_OAUTH_ACTION_RETRY_DELAY)
.await?;
if !resp.successful {
if let Some(ref err) = resp.error {
resp.error = Some(super::error_mapping::format_provider_error(tool, err));
}
}
Ok(resp)
}

pub(super) async fn execute_tool_with_post_oauth_retry(
Expand Down Expand Up @@ -506,58 +526,6 @@ impl ComposioClient {
}
}

/// Calendar query slugs whose `timeMin`/`timeMax` values should be
/// normalized to RFC 3339 timestamps. LLM-generated arguments sometimes
/// emit bare dates like `"2026-05-14"` instead of
/// `"2026-05-14T00:00:00Z"`, which Google Calendar rejects.
const CALENDAR_QUERY_SLUGS: &[&str] = &["GOOGLECALENDAR_EVENTS_LIST", "GOOGLECALENDAR_FIND_EVENT"];

/// Normalize `timeMin`/`timeMax` from bare dates to RFC 3339 for
/// Google Calendar query slugs. The LLM prompt instructs the model to
/// use RFC 3339 format, but some model invocations still produce bare
/// `YYYY-MM-DD` strings.
fn normalize_calendar_query_args(tool: &str, arguments: &mut serde_json::Value) {
if !CALENDAR_QUERY_SLUGS.contains(&tool) {
return;
}
let Some(map) = arguments.as_object_mut() else {
return;
};
for key in &["timeMin", "timeMax"] {
if let Some(serde_json::Value::String(val)) = map.get(*key).cloned() {
if is_bare_date(&val) {
let normalized = format!("{}T00:00:00Z", val);
tracing::debug!(
tool = %tool,
key = %key,
normalized = %normalized,
"[composio] normalized bare date to RFC 3339 for calendar query"
);
map.insert((*key).to_string(), serde_json::Value::String(normalized));
}
}
}
}

/// Returns `true` when `s` is a bare date string like `"2026-05-14"`
/// with no time component.
fn is_bare_date(s: &str) -> bool {
if s.len() != 10 {
return false;
}
let bytes = s.as_bytes();
bytes[0].is_ascii_digit()
&& bytes[1].is_ascii_digit()
&& bytes[2].is_ascii_digit()
&& bytes[3].is_ascii_digit()
&& bytes[4] == b'-'
&& bytes[5].is_ascii_digit()
&& bytes[6].is_ascii_digit()
&& bytes[7] == b'-'
&& bytes[8].is_ascii_digit()
&& bytes[9].is_ascii_digit()
}

fn is_post_oauth_auth_readiness_error(resp: &ComposioExecuteResponse) -> bool {
if resp.successful {
return false;
Expand Down
106 changes: 8 additions & 98 deletions src/openhuman/composio/client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,9 @@ async fn execute_tool_surfaces_non_successful_provider_response() {
);
let base = start_mock_backend(app).await;
let client = build_client_for(base);
let resp = client.execute_tool("GMAIL_SEND_EMAIL", None).await.unwrap();
// Use a slug that bypasses local arg validation in `execute_prepare`
// so the test exercises the gateway-response path, not the pre-flight.
let resp = client.execute_tool("ANY_TOOL", None).await.unwrap();
assert!(
!resp.successful,
"non-successful provider response must be surfaced via the successful flag"
Expand Down Expand Up @@ -854,103 +856,11 @@ async fn execute_tool_sends_tool_slug_in_request_body() {
"tool slug must be forwarded in request body"
);
}
// ── Calendar query argument normalization ───────────────────────

#[test]
fn is_bare_date_rejects_non_date_strings() {
assert!(!is_bare_date(""));
assert!(!is_bare_date("2026-05"));
assert!(!is_bare_date("2026-05-14T00:00:00Z"));
assert!(!is_bare_date("2026/05/14"));
assert!(!is_bare_date("hello-world"));
assert!(!is_bare_date("2026-5-14"));
assert!(!is_bare_date("2026-05-144"));
}

#[test]
fn is_bare_date_accepts_valid_date_strings() {
assert!(is_bare_date("2026-05-14"));
assert!(is_bare_date("2025-01-01"));
assert!(is_bare_date("1999-12-31"));
assert!(is_bare_date("0001-01-01"));
}

#[test]
fn normalize_calendar_query_args_ignores_non_calendar_slugs() {
let mut args = serde_json::json!({ "timeMin": "2026-05-14" });
normalize_calendar_query_args("GMAIL_SEND_EMAIL", &mut args);
assert_eq!(args["timeMin"], "2026-05-14");
}

#[test]
fn normalize_calendar_query_args_converts_bare_date_to_rfc3339() {
let mut args = serde_json::json!({
"connectionId": "conn-1",
"timeMin": "2026-05-14",
"timeMax": "2026-05-15",
});
normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args);
assert_eq!(args["timeMin"], "2026-05-14T00:00:00Z");
assert_eq!(args["timeMax"], "2026-05-15T00:00:00Z");
assert_eq!(args["connectionId"], "conn-1");
}

#[test]
fn normalize_calendar_query_args_preserves_rfc3339_timestamp() {
let mut args = serde_json::json!({
"timeMin": "2026-05-14T00:00:00+05:30",
"timeMax": "2026-05-14T23:59:59Z",
});
normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args);
assert_eq!(args["timeMin"], "2026-05-14T00:00:00+05:30");
assert_eq!(args["timeMax"], "2026-05-14T23:59:59Z");
}

#[test]
fn normalize_calendar_query_args_handles_missing_time_fields() {
let mut args = serde_json::json!({ "connectionId": "conn-1" });
normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args);
assert_eq!(args["connectionId"], "conn-1");
// timeMin/timeMax should not be inserted if absent
assert!(args.get("timeMin").is_none());
}

#[test]
fn normalize_calendar_query_args_handles_non_object_arguments() {
let mut args = serde_json::json!("just a string");
normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args);
assert_eq!(args, "just a string");
}

#[test]
fn normalize_calendar_query_args_handles_calendar_find_event_slug() {
let mut args = serde_json::json!({ "timeMin": "2026-06-01" });
normalize_calendar_query_args("GOOGLECALENDAR_FIND_EVENT", &mut args);
assert_eq!(args["timeMin"], "2026-06-01T00:00:00Z");
}

#[test]
fn normalize_calendar_query_args_normalizes_one_side_when_other_absent() {
// Asymmetric: only timeMin present, timeMax missing. The normalizer
// must convert timeMin without inserting a synthetic timeMax.
let mut args = serde_json::json!({ "timeMin": "2026-05-14" });
normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args);
assert_eq!(args["timeMin"], "2026-05-14T00:00:00Z");
assert!(args.get("timeMax").is_none());
}

#[test]
fn normalize_calendar_query_args_skips_non_string_values() {
// Non-string values (numbers, bools, nulls, objects) must be left
// untouched — the normalizer only rewrites string bare-date inputs.
let mut args = serde_json::json!({
"timeMin": 42,
"timeMax": null,
});
normalize_calendar_query_args("GOOGLECALENDAR_EVENTS_LIST", &mut args);
assert_eq!(args["timeMin"], 42);
assert!(args["timeMax"].is_null());
}
// Calendar bare-date → RFC 3339 normalization is now covered by
// `execute_prepare::prepare_execute_arguments` (PR #1827); see
// `execute_prepare_tests.rs` for the equivalent test surface that
// supersedes the per-slug `normalize_calendar_query_args` helper
// removed alongside the upstream-main merge.

// ── Factory tests (`create_composio_client`) ────────────────────────
//
Expand Down
Loading
Loading