diff --git a/src/openhuman/composio/task_window.rs b/src/openhuman/composio/task_window.rs index 0780033f7..68557be46 100644 --- a/src/openhuman/composio/task_window.rs +++ b/src/openhuman/composio/task_window.rs @@ -142,6 +142,36 @@ fn spec_for(slug: &str) -> Option { ], order_arg: None, }), + // Todoist — `TODOIST_GET_ALL_TASKS` (returns incomplete tasks; the + // brief's catalog slug, fixed from the non-existent + // `TODOIST_GET_ACTIVE_TASKS`). No server-side narrowing: GET tasks does + // NOT accept a `filter` query (that lives on the separate + // `TODOIST_FILTER_TASKS` / `/tasks/filter` endpoint), so enforcement is + // pure post-filter. Todoist's v1 task object timestamps are `added_at` + // (creation) and `updated_at` (modification) — keying on both gives + // created-or-modified semantics. `created_at` is kept as a harmless + // defensive fallback (extra fields only ever keep, never drop). + // CONFIRM-AT-RUNTIME: response envelope via composio_list_tools (Todoist + // is not a native sync provider, so there's no repo extractor to mirror). + "TODOIST_GET_ALL_TASKS" => Some(TaskWindowSpec { + items_paths: &[ + &["tasks"], + &["data", "tasks"], + &["results"], + &["data", "results"], + &["data"], + &[], + ], + ts_fields: &[ + ("added_at", TsFormat::Iso8601), + ("updated_at", TsFormat::Iso8601), + ("created_at", TsFormat::Iso8601), + ("data.added_at", TsFormat::Iso8601), + ("data.updated_at", TsFormat::Iso8601), + ("data.created_at", TsFormat::Iso8601), + ], + order_arg: None, + }), _ => None, } } @@ -175,6 +205,11 @@ pub(crate) fn apply_window_args( .or_insert_with(|| Value::String(since.to_rfc3339())); } + // Todoist has no server-side narrowing here: `TODOIST_GET_ALL_TASKS` + // (GET /tasks) does not accept a `filter` query — that belongs to the + // separate `TODOIST_FILTER_TASKS` (`/tasks/filter`) endpoint. Recency is + // enforced entirely by the post-filter on `added_at`/`updated_at`. + tracing::debug!( target: "composio", slug, diff --git a/src/openhuman/composio/task_window_tests.rs b/src/openhuman/composio/task_window_tests.rs index c21a913df..96d14b701 100644 --- a/src/openhuman/composio/task_window_tests.rs +++ b/src/openhuman/composio/task_window_tests.rs @@ -114,6 +114,53 @@ fn notion_keeps_page_when_either_timestamp_fresh() { assert_eq!(results[0]["id"], "edited"); } +// ── post-filter: Todoist (added_at / updated_at) ──────────────────── + +#[test] +fn todoist_drops_tasks_added_before_floor() { + // Real Todoist v1 field is `added_at` (not created_at). + let resp = ok_resp(json!({ + "tasks": [ + { "id": "old", "added_at": "2026-06-19T09:00:00.000Z" }, + { "id": "new", "added_at": "2026-06-20T12:00:00.000Z" }, + ] + })); + let out = filter_response("TODOIST_GET_ALL_TASKS", resp, floor()); + let tasks = out.data["tasks"].as_array().unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["id"], "new"); +} + +#[test] +fn todoist_keeps_task_updated_in_window_even_if_added_earlier() { + // created-or-modified: added long ago but updated recently → keep. + let resp = ok_resp(json!({ + "tasks": [ + { "id": "touched", "added_at": "2026-01-01T00:00:00.000Z", + "updated_at": "2026-06-20T12:00:00.000Z" }, + { "id": "stale", "added_at": "2026-01-01T00:00:00.000Z", + "updated_at": "2026-01-02T00:00:00.000Z" }, + ] + })); + let out = filter_response("TODOIST_GET_ALL_TASKS", resp, floor()); + let tasks = out.data["tasks"].as_array().unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["id"], "touched"); +} + +#[test] +fn todoist_handles_bare_array_and_data_wrapped_rows() { + // Composio may return a bare array and/or wrap each row under `data`. + let resp = ok_resp(json!([ + { "data": { "id": "old", "added_at": "2026-01-01T00:00:00.000Z" } }, + { "data": { "id": "new", "added_at": "2026-06-20T12:00:00.000Z" } }, + ])); + let out = filter_response("TODOIST_GET_ALL_TASKS", resp, floor()); + let tasks = out.data.as_array().unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["data"]["id"], "new"); +} + // ── conservative behaviors ────────────────────────────────────────── #[test] @@ -201,3 +248,20 @@ fn apply_window_args_noop_for_unknown_slug() { let out = apply_window_args("GMAIL_FETCH_EMAILS", Some(json!({ "a": 1 })), floor()); assert_eq!(out.unwrap(), json!({ "a": 1 })); } + +#[test] +fn todoist_gets_no_server_side_narrowing() { + // GET /tasks has no `filter` param (that's the /tasks/filter endpoint), + // so we must NOT inject one — enforcement is pure post-filter. + let out = apply_window_args( + "TODOIST_GET_ALL_TASKS", + Some(json!({ "limit": 50 })), + floor(), + ) + .unwrap(); + assert!( + out.get("filter").is_none(), + "must not inject a filter param" + ); + assert_eq!(out["limit"], 50, "caller args preserved"); +} diff --git a/src/openhuman/memory_sync/composio/providers/catalogs_productivity.rs b/src/openhuman/memory_sync/composio/providers/catalogs_productivity.rs index 64a00ff6e..424efde9c 100644 --- a/src/openhuman/memory_sync/composio/providers/catalogs_productivity.rs +++ b/src/openhuman/memory_sync/composio/providers/catalogs_productivity.rs @@ -485,11 +485,16 @@ pub const TODOIST_CURATED: &[CuratedTool] = &[ scope: ToolScope::Read, }, CuratedTool { - slug: "TODOIST_GET_ACTIVE_TASKS", + // Composio's catalog has no `TODOIST_GET_ACTIVE_TASKS`; the real + // incomplete-tasks slug is `TODOIST_GET_ALL_TASKS` (docs.composio.dev/ + // toolkits/todoist). The old slug was rejected as an unknown action. + slug: "TODOIST_GET_ALL_TASKS", scope: ToolScope::Read, }, CuratedTool { - slug: "TODOIST_GET_COMPLETED_TASKS", + // Real completed-tasks slug; `TODOIST_GET_COMPLETED_TASKS` does not + // exist in Composio's catalog. + slug: "TODOIST_LIST_COMPLETED_TASKS", scope: ToolScope::Read, }, CuratedTool {