//! Focused raw coverage for Composio memory-sync providers. //! //! These tests stay local: temp workspaces plus a loopback backend that //! returns Composio execute envelopes. Run with `--test-threads=1` because //! config, HOME, and OPENHUMAN_WORKSPACE are process globals. use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use axum::routing::any; use axum::{Json, Router}; use serde_json::{json, Value}; use tempfile::TempDir; use openhuman_core::core::event_bus::{DomainEvent, EventHandler}; use openhuman_core::openhuman::config::Config; use openhuman_core::openhuman::credentials::{ AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, }; use openhuman_core::openhuman::memory::global as memory_global; use openhuman_core::openhuman::memory::jobs::drain_until_idle; use openhuman_core::openhuman::memory::tree_source::get_or_create_source_tree; use openhuman_core::openhuman::memory_store::chunks::store::{ count_chunks_by_lifecycle_status, get_chunk_raw_refs, list_chunks, ListChunksQuery, CHUNK_STATUS_BUFFERED, }; use openhuman_core::openhuman::memory_store::chunks::types::SourceKind; use openhuman_core::openhuman::memory_store::content::read::read_chunk_body; use openhuman_core::openhuman::memory_store::trees::store as tree_store; use openhuman_core::openhuman::memory_sync::composio::bus::{ ComposioConfigChangedSubscriber, ComposioConnectionCreatedSubscriber, ComposioTriggerSubscriber, }; use openhuman_core::openhuman::memory_sync::composio::providers::clickup::ClickUpProvider; use openhuman_core::openhuman::memory_sync::composio::providers::github::GitHubProvider; use openhuman_core::openhuman::memory_sync::composio::providers::gmail::ingest as gmail_ingest; use openhuman_core::openhuman::memory_sync::composio::providers::gmail::GmailProvider; use openhuman_core::openhuman::memory_sync::composio::providers::linear::LinearProvider; use openhuman_core::openhuman::memory_sync::composio::providers::notion::NotionProvider; use openhuman_core::openhuman::memory_sync::composio::providers::slack::ingest as slack_ingest; use openhuman_core::openhuman::memory_sync::composio::providers::slack::{ SlackMessage, SlackProvider, }; use openhuman_core::openhuman::memory_sync::composio::providers::sync_state::SyncState; use openhuman_core::openhuman::memory_sync::composio::providers::{ ComposioProvider, ProviderContext, SyncReason, TaskFetchFilter, }; use openhuman_core::openhuman::memory_tree::tree::bucket_seal::LabelStrategy; static ENV_LOCK: OnceLock> = OnceLock::new(); fn env_lock() -> std::sync::MutexGuard<'static, ()> { ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|e| e.into_inner()) } struct EnvGuard { key: &'static str, old: Option, } impl EnvGuard { fn set(key: &'static str, value: impl Into) -> Self { let old = std::env::var(key).ok(); unsafe { std::env::set_var(key, value.into()) }; Self { key, old } } fn set_path(key: &'static str, value: &Path) -> Self { Self::set(key, value.to_string_lossy().into_owned()) } fn unset(key: &'static str) -> Self { let old = std::env::var(key).ok(); unsafe { std::env::remove_var(key) }; Self { key, old } } } impl Drop for EnvGuard { fn drop(&mut self) { match &self.old { Some(value) => unsafe { std::env::set_var(self.key, value) }, None => unsafe { std::env::remove_var(self.key) }, } } } fn config_in(tmp: &TempDir) -> Config { let mut config = Config { config_path: tmp.path().join("config.toml"), workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), ..Config::default() }; config.secrets.encrypt = false; config } async fn persist_config(config: &Config) { std::fs::create_dir_all(&config.workspace_dir).expect("workspace dir"); config.save().await.expect("save config"); } fn store_session(config: &Config) { AuthService::from_config(config) .store_provider_token( APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, "round17-session-token", HashMap::new(), true, ) .expect("store app session token"); } async fn loopback_router(router: Router) -> (String, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind loopback"); let addr = listener.local_addr().expect("loopback addr"); let handle = tokio::spawn(async move { axum::serve(listener, router).await.expect("serve loopback"); }); (format!("http://{addr}"), handle) } fn execute_envelope(data: Value) -> Value { json!({ "success": true, "data": { "data": data, "successful": true, "error": null, "costUsd": 0.0 } }) } fn execute_response_for(body: &Value) -> Value { let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let args = body.get("arguments").cloned().unwrap_or_else(|| json!({})); match tool { "SLACK_TEST_AUTH" => execute_envelope(json!({ "user_id": "U17A", "user": "round17", "team": "Coverage Workspace", "team_id": "T17", "url": "https://coverage.slack.com" })), "SLACK_RETRIEVE_DETAILED_USER_INFORMATION" => execute_envelope(json!({ "user": { "real_name": "Round Seventeen", "profile": { "email": "round17@example.test", "image_192": "https://example.test/avatar.png" } } })), "SLACK_FETCH_TEAM_INFO" => execute_envelope(json!({ "team": { "email_domain": "example.test", "icon": { "image_132": "https://example.test/team.png" } } })), "SLACK_LIST_ALL_USERS" => { let has_cursor = args.get("cursor").is_some(); execute_envelope(json!({ "members": [ { "id": if has_cursor { "U17B" } else { "U17A" }, "profile": { "display_name": if has_cursor { "" } else { "Ava" }, "real_name": if has_cursor { "Ben" } else { "" } }, "name": if has_cursor { "ben" } else { "ava" } }, { "id": "", "name": "dropped" } ], "response_metadata": { "next_cursor": if has_cursor { "" } else { "page-2" } } })) } "SLACK_LIST_CONVERSATIONS" => execute_envelope(json!({ "channels": [ { "id": "C17", "name": "coverage", "is_private": false }, { "id": "G17", "name": "private-coverage", "is_private": true }, { "id": "", "name": "dropped" } ], "response_metadata": { "next_cursor": "" } })), "SLACK_FETCH_CONVERSATION_HISTORY" => { let channel = args.get("channel").and_then(Value::as_str).unwrap_or(""); execute_envelope(json!({ "messages": [ { "ts": if channel == "G17" { "1714004200.000300" } else { "1714003200.000100" }, "user": "U17A", "text": if channel == "G17" { "private note for <@U17B>" } else { "shipping coverage with <@U17B>" }, "thread_ts": "1714003200.000100", "permalink": "https://coverage.slack.com/archives/C17/p1714003200000100" }, { "ts": "1714003300.000200", "user": "U17B", "text": " " } ], "response_metadata": { "next_cursor": "" } })) } "SLACK_SEARCH_MESSAGES" => execute_envelope(json!({ "messages": { "matches": [ { "ts": "1714005200.000400", "user": "U17B", "text": "search backfill hit for <@U17A>", "channel": { "id": "C17" }, "permalink": "https://coverage.slack.com/archives/C17/p1714005200000400" }, { "ts": "1714005300.000500", "user": "U17B", "text": "orphan match stays out", "channel": { "name": "missing-id" } } ], "paging": { "pages": 1 } } })), "GITHUB_GET_THE_AUTHENTICATED_USER" => execute_envelope(json!({ "login": "octo-round17", "name": "Octo Coverage", "email": "octo@example.test", "avatar_url": "https://example.test/octo.png", "html_url": "https://github.com/octo-round17" })), "GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS" => execute_envelope(json!({ "items": [ { "id": 1701, "title": "Cover GitHub provider", "body": "Raw provider coverage", "state": "open", "labels": [{ "name": "coverage" }], "assignee": { "login": "octo-round17" }, "updated_at": "2026-05-29T10:00:00Z", "html_url": "https://github.com/tinyhumansai/openhuman/issues/1701" }, { "title": "Missing id is skipped", "updated_at": "2026-05-29T09:00:00Z" } ], "total_count": 2 })), "CLICKUP_GET_AUTHORIZED_USER" => execute_envelope(json!({ "user": { "id": 9917, "username": "click round17", "email": "click17@example.test", "profilePicture": "https://example.test/click.png" } })), "CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES" => execute_envelope(json!({ "teams": [ { "id": "team_17", "name": "Coverage Team" }, { "name": "missing id" } ] })), "CLICKUP_GET_FILTERED_TEAM_TASKS" => execute_envelope(json!({ "tasks": [ { "id": "task_17", "name": "Cover ClickUp provider", "text_content": "Exercise task persistence", "status": { "status": "to do" }, "assignees": [{ "username": "click round17" }], "priority": { "priority": "high" }, "date_updated": "1798545600000", "url": "https://app.clickup.com/t/task_17" }, { "name": "missing id skips", "date_updated": "1798545500000" } ] })), _ => execute_envelope(json!({ "unknown_tool": tool, "arguments": args })), } } async fn configured_loopback_context( tmp: &TempDir, toolkit: &str, connection_id: &str, requests: Arc>>, ) -> (Config, ProviderContext, tokio::task::JoinHandle<()>) { let mut config = config_in(tmp); let router = Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); Json(execute_response_for(&body)) } }), ); let (base, server) = loopback_router(router).await; config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory client"); let ctx = ProviderContext { config: Arc::new(config.clone()), toolkit: toolkit.to_string(), connection_id: Some(connection_id.to_string()), usage: Default::default(), max_items: None, sync_depth_days: None, }; (config, ctx, server) } #[tokio::test] async fn gmail_ingest_archives_account_messages_and_legacy_participant_buckets() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let config = config_in(&tmp); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); persist_config(&config).await; let page = vec![ json!({ "id": "gmail-round17-a", "from": "Ava ", "to": ["Ben ", "Casey "], "cc": "ignored@example.test", "subject": "Re: Coverage thread", "date": "2026-05-29T10:00:00Z", "markdown": "First useful message body." }), json!({ "id": "gmail-round17-b", "from": "ben@example.test", "to": "ava@example.test, casey@example.test", "subject": "Fwd: Coverage thread", "internalDate": "1780052400000", "markdown": "Second useful message body." }), json!({ "id": "gmail-round17-empty", "from": "nobody@example.test", "to": "ava@example.test", "subject": "No archive body", "date": "2026-05-29T12:00:00Z", "markdown": " " }), json!({ "from": "missing-id@example.test", "to": "ava@example.test", "subject": "No id", "date": "2026-05-29T13:00:00Z", "markdown": "missing id skips per-account ingest" }), ]; let chunks = gmail_ingest::ingest_page_into_memory_tree( &config, "owner-round17", Some("round17@example.test"), &page, ) .await .expect("per-account gmail ingest"); assert!(chunks >= 2, "expected useful account messages to chunk"); let raw_root = config.memory_tree_content_root().join("raw"); let archived: Vec<_> = walk_files(&raw_root) .into_iter() .filter(|p| { p.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.contains("gmail-round17-a")) }) .collect(); assert_eq!(archived.len(), 1, "raw archive should include message a"); let archived_body = std::fs::read_to_string(&archived[0]).expect("archived body"); assert!(archived_body.contains("**From:** Ava")); assert!(archived_body.contains("First useful message body.")); let legacy = gmail_ingest::ingest_page_into_memory_tree( &config, "owner-round17", None, &[ json!({ "id": "legacy-orphan", "from": "not an address", "to": [], "subject": "Fw: ", "date": "2026-05-29T14:00:00Z", "markdown": "orphan fallback body" }), json!({ "from": "", "to": [], "subject": "Skipped", "date": "2026-05-29T15:00:00Z", "markdown": "no id and no participants" }), ], ) .await .expect("legacy gmail ingest"); assert!(legacy >= 1, "orphan fallback bucket should ingest"); } #[tokio::test] async fn gmail_raw_backed_messages_drain_into_source_tree_summary() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); let config = config_in(&tmp); persist_config(&config).await; let page = vec![ json!({ "id": "gmail-tree-a", "from": "Ava ", "to": ["Ben "], "subject": "Phoenix launch plan", "date": "2026-05-29T10:00:00Z", "markdown": "Phoenix migration launches Friday. Ava owns rollout validation and Ben owns customer notices." }), json!({ "id": "gmail-tree-b", "from": "Ben ", "to": ["Ava "], "subject": "Re: Phoenix launch plan", "date": "2026-05-29T10:05:00Z", "markdown": "Confirmed. Customer notices go out after staging checks and the rollback doc is reviewed." }), ]; let outcome = gmail_ingest::ingest_page_into_memory_tree_with_outcome( &config, "owner-gmail-tree", Some("flow@example.test"), &page, ) .await .expect("gmail ingest outcome"); assert_eq!( outcome.item_ids_ingested, vec!["gmail-tree-a".to_string(), "gmail-tree-b".to_string()] ); assert!( outcome.chunks_written >= 2, "expected one or more chunks per message" ); let source_id = "gmail:flow-at-example-dot-test"; let chunks = list_chunks( &config, &ListChunksQuery { source_kind: Some(SourceKind::Email), source_id: Some(source_id.to_string()), limit: Some(10), ..Default::default() }, ) .expect("list gmail chunks"); assert_eq!(chunks.len(), outcome.chunks_written); for chunk in &chunks { let refs = get_chunk_raw_refs(&config, &chunk.id) .expect("raw refs lookup") .expect("raw refs must be set before extract can run"); assert_eq!(refs.len(), 1); assert!( refs[0].path.contains("gmail-tree-"), "raw ref should point at the source message file: {:?}", refs[0].path ); let full_body = read_chunk_body(&config, &chunk.id).expect("read raw-backed chunk body"); assert!( full_body.contains("Phoenix") || full_body.contains("Customer notices"), "chunk body should hydrate from raw archive, got: {full_body}" ); } drain_until_idle(&config) .await .expect("extract and append jobs should drain"); let buffered = count_chunks_by_lifecycle_status(&config, CHUNK_STATUS_BUFFERED).expect("buffered count"); assert_eq!(buffered, outcome.chunks_written as u64); let tree = get_or_create_source_tree(&config, source_id).expect("source tree"); let l0 = tree_store::get_buffer(&config, &tree.id, 0).expect("source L0 buffer"); assert_eq!( l0.item_ids.len(), outcome.chunks_written, "all Gmail chunks should reach the source tree buffer" ); let sealed = openhuman_core::openhuman::memory_tree::tree::flush::flush_stale_buffers( &config, chrono::Duration::zero(), &LabelStrategy::Empty, ) .await .expect("force flush gmail source tree"); assert!(sealed > 0, "low-volume Gmail source should seal on flush"); let l1 = tree_store::list_summaries_at_level(&config, &tree.id, 1).expect("list source summaries"); assert!( !l1.is_empty(), "Gmail source tree should have a sealed summary after flush" ); let summary_body = openhuman_core::openhuman::memory_store::content::read::read_summary_body( &config, &l1[0].id, ) .expect("read gmail summary body"); assert!( summary_body.contains("Phoenix") || summary_body.contains("Customer"), "summary should preserve Gmail content, got: {summary_body}" ); } #[tokio::test] async fn slack_provider_profile_postprocess_trigger_and_ingest_use_loopback_composio() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (config, ctx, server) = configured_loopback_context(&tmp, "slack", "conn-slack-round17", Arc::clone(&requests)) .await; let provider = SlackProvider::new(); let profile = provider .fetch_user_profile(&ctx) .await .expect("slack profile"); assert_eq!(profile.username.as_deref(), Some("U17A")); assert_eq!(profile.email.as_deref(), Some("round17@example.test")); let mut channels = json!({ "data": { "channels": [ { "id": "C17", "name": "coverage", "is_private": false }, { "id": "", "name": "dropped" } ] } }); provider.post_process_action_result("SLACK_LIST_CONVERSATIONS", None, &mut channels); assert_eq!(channels["channels"].as_array().unwrap().len(), 1); let mut history = json!({ "data": { "messages": [ { "ts": "1714003200.000100", "user": "U17A", "text": "shipping coverage with <@U17B>", "permalink": "https://coverage.slack.com/archives/C17/p1714003200000100" }, { "ts": "1714003300.000200", "user": "U17B", "text": " " } ] } }); provider.post_process_action_result("SLACK_FETCH_CONVERSATION_HISTORY", None, &mut history); assert_eq!(history["messages"].as_array().unwrap().len(), 1); provider .on_trigger( &ctx, "SLACK_CHANNEL_ARCHIVE", &json!({ "event": "channel" }), ) .await .expect("slack non-message trigger"); let messages = vec![ SlackMessage { channel_id: "C17".to_string(), channel_name: "coverage".to_string(), is_private: false, author: "Ava".to_string(), author_id: "U17A".to_string(), text: "Slack raw archive body".to_string(), timestamp: chrono::DateTime::parse_from_rfc3339("2026-05-29T10:00:00Z") .unwrap() .with_timezone(&chrono::Utc), ts_raw: "1714003200.000100".to_string(), thread_ts: Some("1714003200.000100".to_string()), permalink: Some( "https://coverage.slack.com/archives/C17/p1714003200000100".to_string(), ), }, SlackMessage { channel_id: "G17".to_string(), channel_name: "private-coverage".to_string(), is_private: true, author: String::new(), author_id: String::new(), text: " ".to_string(), timestamp: chrono::DateTime::parse_from_rfc3339("2026-05-29T10:01:00Z") .unwrap() .with_timezone(&chrono::Utc), ts_raw: "1714003260.000200".to_string(), thread_ts: None, permalink: None, }, ]; let chunks = slack_ingest::ingest_page_into_memory_tree( &config, "owner-round17", "conn-slack-round17", &messages, ) .await .expect("slack ingest"); assert!(chunks >= 1); let called_tools: Vec = requests .lock() .unwrap() .iter() .filter_map(|b| b.get("tool").and_then(Value::as_str).map(str::to_string)) .collect(); assert!(called_tools.contains(&"SLACK_TEST_AUTH".to_string())); assert!(called_tools.contains(&"SLACK_RETRIEVE_DETAILED_USER_INFORMATION".to_string())); server.abort(); } #[tokio::test] async fn github_clickup_and_composio_bus_cover_provider_branches() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (_config, github_ctx, server) = configured_loopback_context(&tmp, "github", "conn-github-round17", Arc::clone(&requests)) .await; let github = GitHubProvider::new(); let github_profile = github .fetch_user_profile(&github_ctx) .await .expect("github profile"); assert_eq!(github_profile.username.as_deref(), Some("octo-round17")); let github_tasks = github .fetch_tasks( &github_ctx, &TaskFetchFilter { repo: Some("tinyhumansai/openhuman".to_string()), labels: vec!["coverage".to_string()], state: Some("open".to_string()), max: 5, ..TaskFetchFilter::default() }, ) .await .expect("github tasks"); assert_eq!(github_tasks.len(), 1); assert_eq!(github_tasks[0].external_id, "1701"); let github_sync = github .sync(&github_ctx, SyncReason::ConnectionCreated) .await .expect("github sync"); assert_eq!(github_sync.items_ingested, 1); let click_ctx = ProviderContext { config: github_ctx.config.clone(), toolkit: "clickup".to_string(), connection_id: Some("conn-clickup-round17".to_string()), usage: Default::default(), max_items: None, sync_depth_days: None, }; let clickup = ClickUpProvider::new(); let click_profile = clickup .fetch_user_profile(&click_ctx) .await .expect("clickup profile"); assert_eq!(click_profile.username.as_deref(), Some("9917")); let click_tasks = clickup .fetch_tasks( &click_ctx, &TaskFetchFilter { team_id: Some("team_17".to_string()), list_id: Some("list_17".to_string()), max: 5, ..TaskFetchFilter::default() }, ) .await .expect("clickup tasks"); assert_eq!(click_tasks.len(), 1); assert_eq!(click_tasks[0].external_id, "task_17"); let click_sync = clickup .sync(&click_ctx, SyncReason::Manual) .await .expect("clickup sync"); assert_eq!(click_sync.items_ingested, 1); let trigger_sub = ComposioTriggerSubscriber::new(); assert_eq!(trigger_sub.name(), "composio::trigger"); assert_eq!(trigger_sub.domains().unwrap(), &["composio"]); trigger_sub .handle(&DomainEvent::ComposioTriggerReceived { toolkit: "slack".to_string(), trigger: "SLACK_MESSAGE_POSTED".to_string(), metadata_id: "id-round17".to_string(), metadata_uuid: "uuid-round17".to_string(), payload: json!({ "text": "hello" }), }) .await; let connection_sub = ComposioConnectionCreatedSubscriber::new(); assert_eq!(connection_sub.name(), "composio::connection_created"); connection_sub .handle(&DomainEvent::ComposioConfigChanged { mode: "backend".to_string(), api_key_set: false, }) .await; let config_sub = ComposioConfigChangedSubscriber::new(); assert_eq!(config_sub.name(), "composio::config_changed"); config_sub .handle(&DomainEvent::ComposioConfigChanged { mode: "direct".to_string(), api_key_set: true, }) .await; let called_tools: Vec = requests .lock() .unwrap() .iter() .filter_map(|b| b.get("tool").and_then(Value::as_str).map(str::to_string)) .collect(); assert!(called_tools.contains(&"GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS".to_string())); assert!(called_tools.contains(&"CLICKUP_GET_FILTERED_TEAM_TASKS".to_string())); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // Slack cap enforcement // // Proves that max_items=N caps Slack ingestion to exactly N messages even // when a single channel/page returns more than N messages. The mock returns // 5 messages in one channel; with max_items=2 only 2 must be persisted. // ───────────────────────────────────────────────────────────────────────── /// Build a loopback router for the Slack cap test. Returns 5 messages for /// the single channel when `SLACK_FETCH_CONVERSATION_HISTORY` is called; /// all other Slack bootstrap calls (auth, users, channel listing) return /// minimal but valid responses. fn slack_cap_router(requests: Arc>>) -> Router { Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let resp = match tool { "SLACK_TEST_AUTH" => execute_envelope(json!({ "user_id": "UCAP", "user": "capuser", "team": "Cap Workspace", "team_id": "TCAP", "url": "https://cap.slack.com" })), "SLACK_RETRIEVE_DETAILED_USER_INFORMATION" => execute_envelope(json!({ "user": { "real_name": "Cap User", "profile": { "email": "cap@example.test" } } })), "SLACK_FETCH_TEAM_INFO" => execute_envelope(json!({ "team": { "email_domain": "example.test" } })), // User directory — one member, no next page. "SLACK_LIST_ALL_USERS" => execute_envelope(json!({ "members": [ { "id": "UCAP", "name": "capuser", "profile": { "real_name": "Cap User" } } ], "response_metadata": { "next_cursor": "" } })), // One channel, no next page. "SLACK_LIST_CONVERSATIONS" => execute_envelope(json!({ "channels": [ { "id": "CCAP", "name": "cap-channel", "is_private": false } ], "response_metadata": { "next_cursor": "" } })), // Return 5 distinct messages for the single channel; // the cap is 2 so only 2 must be persisted. "SLACK_FETCH_CONVERSATION_HISTORY" => execute_envelope(json!({ "messages": [ { "ts": "1800000001.000001", "user": "UCAP", "text": "cap message 1", "permalink": "https://cap.slack.com/archives/CCAP/p1800000001000001" }, { "ts": "1800000002.000002", "user": "UCAP", "text": "cap message 2", "permalink": "https://cap.slack.com/archives/CCAP/p1800000002000002" }, { "ts": "1800000003.000003", "user": "UCAP", "text": "cap message 3", "permalink": "https://cap.slack.com/archives/CCAP/p1800000003000003" }, { "ts": "1800000004.000004", "user": "UCAP", "text": "cap message 4", "permalink": "https://cap.slack.com/archives/CCAP/p1800000004000004" }, { "ts": "1800000005.000005", "user": "UCAP", "text": "cap message 5", "permalink": "https://cap.slack.com/archives/CCAP/p1800000005000005" } ], "response_metadata": { "next_cursor": "" } })), _ => execute_envelope(json!({})), }; Json(resp) } }), ) } #[tokio::test] async fn slack_sync_max_items_caps_ingest_to_exact_count() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // Disable inter-call pacing so the test runs quickly. let _pacing = EnvGuard::set("OPENHUMAN_SLACK_INTER_CALL_PACING_MS", "0"); let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(slack_cap_router(Arc::clone(&requests))).await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "slack".to_string(), connection_id: Some("conn-slack-cap".to_string()), usage: Default::default(), // The mock returns 5 messages; cap is 2 — only 2 must be ingested. max_items: Some(2), sync_depth_days: None, }; let outcome = SlackProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("slack cap sync"); assert_eq!( outcome.items_ingested, 2, "max_items=2 must cap Slack ingest to exactly 2 even though the channel/page held 5" ); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // Gmail cap enforcement // // Proves that max_items=N caps Gmail ingestion to exactly N messages even // when a single GMAIL_FETCH_EMAILS page returns more than N. The mock // returns 5 messages in one page; with max_items=2 only 2 must be // persisted. // // Gmail messages arrive in Composio's "upstream" shape before post-process // reshapes them into the slim envelope: // - messageId → id (used by MESSAGE_ID_PATHS dedup) // - sender → from (used by ingest bucketing) // - messageText → markdown body (from extract_markdown_body fallback) // - messageTimestamp → date // All 5 messages are unique (different messageId) and valid (non-empty // messageText), so without the cap every one would be ingested. // ───────────────────────────────────────────────────────────────────────── /// Build M distinct, valid Gmail messages in the upstream (pre-post-process) /// Composio shape. Uses `messageId` / `sender` / `messageText` / /// `messageTimestamp` so `reshape_message` maps them correctly into the slim /// envelope that `ingest_page_into_memory_tree` expects. fn gmail_cap_messages(m: usize) -> Vec { (1..=m) .map(|i| { json!({ "messageId": format!("gmail-cap-msg-{i}"), "threadId": format!("thread-cap-{i}"), "sender": format!("sender{i}@cap.example.test"), "to": "recipient@cap.example.test", "subject": format!("Cap test message {i}"), "messageTimestamp": format!("2026-06-0{}T10:00:00Z", (i % 9) + 1), "messageText": format!("Body of cap test message {i}. Sufficient content.") }) }) .collect() } /// Loopback router that answers the two Gmail sync tool calls. /// GMAIL_GET_PROFILE returns a minimal valid profile; GMAIL_FETCH_EMAILS /// returns one page containing all `messages` and no next-page token. fn gmail_cap_router(messages: Vec, requests: Arc>>) -> Router { Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let messages = messages.clone(); let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let resp = match tool { "GMAIL_GET_PROFILE" => execute_envelope(json!({ "emailAddress": "cap@example.test", "messagesTotal": messages.len() })), "GMAIL_FETCH_EMAILS" => execute_envelope(json!({ "messages": messages, "nextPageToken": "" })), _ => execute_envelope(json!({})), }; Json(resp) } }), ) } #[tokio::test] async fn gmail_sync_max_items_caps_ingest_to_exact_count() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One page returns 5 valid messages; the cap is 2. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(gmail_cap_router( gmail_cap_messages(5), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "gmail".to_string(), connection_id: Some("conn-gmail-cap".to_string()), usage: Default::default(), // Mock returns 5 messages; cap is 2 — only 2 must be ingested. max_items: Some(2), sync_depth_days: None, }; let outcome = GmailProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("gmail cap sync"); assert_eq!( outcome.items_ingested, 2, "max_items=2 must cap Gmail ingest to exactly 2 even though the page held 5" ); server.abort(); } #[tokio::test] async fn gmail_sync_depth_days_injects_after_floor_into_query() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // Gmail applies sync_depth_days server-side: on the first sync (no cursor) // it must inject an `after:` qualifier into the GMAIL_FETCH_EMAILS // query, proving the window actually narrows what is fetched. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(gmail_cap_router( gmail_cap_messages(1), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "gmail".to_string(), connection_id: Some("conn-gmail-depth".to_string()), usage: Default::default(), max_items: None, sync_depth_days: Some(7), }; GmailProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("gmail depth sync"); let reqs = requests.lock().unwrap(); let fetch = reqs .iter() .find(|b| b.get("tool").and_then(Value::as_str) == Some("GMAIL_FETCH_EMAILS")) .expect("a GMAIL_FETCH_EMAILS request was issued"); let query = fetch .get("arguments") .and_then(|a| a.get("query")) .and_then(Value::as_str) .unwrap_or(""); assert!( query.contains("after:"), "sync_depth_days=7 must inject an `after:` floor into the query, got: {query}" ); server.abort(); } /// Router whose `GMAIL_FETCH_EMAILS` always returns the same page **with a /// non-empty `nextPageToken`** — so absent an all-synced early stop the provider /// would paginate to the page ceiling. Used to pin `stop_on_empty_pending`. fn gmail_repeating_router(messages: Vec, requests: Arc>>) -> Router { Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let messages = messages.clone(); let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let resp = match tool { "GMAIL_GET_PROFILE" => { execute_envelope(json!({ "emailAddress": "stop@example.test" })) } "GMAIL_FETCH_EMAILS" => execute_envelope(json!({ "messages": messages, "nextPageToken": "more" })), _ => execute_envelope(json!({})), }; Json(resp) } }), ) } // Replaces gmail's dropped `last_seen_id` head-unchanged optimization: prove the // provider halts after a single `GMAIL_FETCH_EMAILS` round-trip when the first // page is already fully synced. We pre-seed `synced_ids` with the page's message // ids and leave the cursor `None` — so the cursor/depth boundary cannot fire and // **only** `stop_on_empty_pending` can stop the loop. The router hands back a // perpetual `nextPageToken`, so without the early stop the provider would walk // every page up to the ceiling. #[tokio::test] async fn gmail_sync_stops_after_an_all_already_synced_page() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(gmail_repeating_router( gmail_cap_messages(3), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); let memory = memory_global::init(config.workspace_dir.clone()).expect("init global memory"); // Pre-seed: the 3 page-1 message ids are already synced; cursor stays None. let mut state = SyncState::new("gmail", "conn-gmail-stop"); for i in 1..=3 { state.mark_synced(format!("gmail-cap-msg-{i}")); } state .save(&memory) .await .expect("save pre-seeded sync state"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "gmail".to_string(), connection_id: Some("conn-gmail-stop".to_string()), usage: Default::default(), max_items: None, sync_depth_days: None, }; let outcome = GmailProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("gmail sync"); assert_eq!( outcome.items_ingested, 0, "every page-1 message was already synced — nothing to ingest" ); let fetch_calls = requests .lock() .unwrap() .iter() .filter(|b| b.get("tool").and_then(Value::as_str) == Some("GMAIL_FETCH_EMAILS")) .count(); assert_eq!( fetch_calls, 1, "stop_on_empty_pending must halt after one all-synced page, not paginate the \ perpetual nextPageToken — got {fetch_calls} GMAIL_FETCH_EMAILS calls" ); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // Notion cap enforcement // // Proves that max_items=N caps Notion ingestion to exactly N pages even // when a single NOTION_FETCH_DATA page returns more than N. The mock // returns 5 pages; with max_items=2 only 2 must be persisted. // // sync_depth_days is None and all items carry a recent last_edited_time // so no depth-filter skipping happens — the cap is the only thing that // limits ingestion. // ───────────────────────────────────────────────────────────────────────── /// Build M distinct, valid Notion page objects. Each has a unique id and a /// recent `last_edited_time` so the depth filter (when enabled) would keep /// them all. fn notion_cap_pages(m: usize) -> Vec { (1..=m) .map(|i| { json!({ "id": format!("notion-cap-page-{i:04}"), "object": "page", "last_edited_time": format!("2026-06-0{}T10:00:00.000Z", (i % 9) + 1), "properties": { "Name": { "type": "title", "title": [{ "plain_text": format!("Cap page {i}") }] } }, "url": format!("https://www.notion.so/cap-page-{i}") }) }) .collect() } /// Loopback router for the Notion cap test. /// NOTION_GET_ABOUT_ME returns a minimal identity; NOTION_FETCH_DATA returns /// one page with all `pages` as results and no next_cursor. fn notion_cap_router(pages: Vec, requests: Arc>>) -> Router { Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let pages = pages.clone(); let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let resp = match tool { "NOTION_GET_ABOUT_ME" => execute_envelope(json!({ "id": "notion-cap-user", "name": "Cap User", "type": "bot" })), "NOTION_FETCH_DATA" => execute_envelope(json!({ "results": pages, "next_cursor": null, "has_more": false })), _ => execute_envelope(json!({})), }; Json(resp) } }), ) } #[tokio::test] async fn notion_sync_max_items_caps_ingest_to_exact_count() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One page returns 5 valid Notion pages; the cap is 2. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(notion_cap_router( notion_cap_pages(5), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "notion".to_string(), connection_id: Some("conn-notion-cap".to_string()), usage: Default::default(), // Mock returns 5 pages; cap is 2 — only 2 must be ingested. max_items: Some(2), sync_depth_days: None, }; let outcome = NotionProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("notion cap sync"); assert_eq!( outcome.items_ingested, 2, "max_items=2 must cap Notion ingest to exactly 2 even though the page held 5" ); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // Notion sync_depth_days enforcement (via the shared orchestrator) // // Proves the generic orchestrator's depth window actually drops items older // than the floor end-to-end through the real Notion provider. The mock returns // 2 recent pages (far-future `last_edited_time`, always inside the window) and // 3 ancient pages (year-2000, always outside it), in the descending order the // provider requests. With sync_depth_days=7 only the 2 recent pages persist — // the orchestrator truncates the page at the first item below the floor. // ───────────────────────────────────────────────────────────────────────── /// Build `recent` + `old` Notion pages in descending `last_edited_time` order. /// Recent pages use a far-future timestamp (always within any depth window); /// old pages use a year-2000 timestamp (always outside it). fn notion_depth_pages(recent: usize, old: usize) -> Vec { let mut pages = Vec::new(); for i in 0..recent { pages.push(json!({ "id": format!("notion-recent-{i:04}"), "object": "page", "last_edited_time": format!("2099-12-{:02}T10:00:00.000Z", 28 - i), "properties": { "Name": { "type": "title", "title": [{ "plain_text": format!("Recent {i}") }] } } })); } for i in 0..old { pages.push(json!({ "id": format!("notion-old-{i:04}"), "object": "page", "last_edited_time": format!("2000-01-{:02}T10:00:00.000Z", 3 - i), "properties": { "Name": { "type": "title", "title": [{ "plain_text": format!("Old {i}") }] } } })); } pages } #[tokio::test] async fn notion_sync_depth_days_filters_old_pages() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One page: 2 recent + 3 ancient. With a 7-day window only the 2 recent // pages must be ingested. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(notion_cap_router( notion_depth_pages(2, 3), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "notion".to_string(), connection_id: Some("conn-notion-depth".to_string()), usage: Default::default(), max_items: None, // 7-day window: drops the year-2000 pages, keeps the far-future ones. sync_depth_days: Some(7), }; let outcome = NotionProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("notion depth sync"); assert_eq!( outcome.items_ingested, 2, "sync_depth_days=7 must drop the 3 year-2000 pages and keep the 2 recent ones" ); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // Linear cap enforcement // // Proves that max_items=N caps Linear ingestion to exactly N issues even // when a single LINEAR_LIST_LINEAR_ISSUES page returns more than N. // // Tool sequence: // 1. LINEAR_LIST_LINEAR_USERS { isMe: true } → viewer id // 2. LINEAR_LIST_LINEAR_ISSUES (assigneeId=...) → nodes of issues // // Issues are in Linear's `{ nodes: [...], pageInfo: {...} }` shape. // We return hasNextPage=false so pagination stops after one page. // Each issue has a unique id and a recent updatedAt. // ───────────────────────────────────────────────────────────────────────── /// Build M distinct, valid Linear issue objects. fn linear_cap_issues(m: usize) -> Vec { (1..=m) .map(|i| { json!({ "id": format!("linear-cap-issue-{i:04}"), "identifier": format!("ENG-{i}"), "title": format!("Cap issue {i}"), "updatedAt": format!("2026-06-0{}T10:00:00.000Z", (i % 9) + 1), "url": format!("https://linear.app/cap/issue/ENG-{i}"), "description": format!("Description for cap issue {i}.") }) }) .collect() } /// Loopback router for the Linear cap test. /// LINEAR_LIST_LINEAR_USERS returns a single viewer node so `resolve_viewer_id` /// succeeds. LINEAR_LIST_LINEAR_ISSUES returns one page with all issues and no /// next-page cursor (hasNextPage=false). fn linear_cap_router(issues: Vec, requests: Arc>>) -> Router { Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let issues = issues.clone(); let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let resp = match tool { "LINEAR_LIST_LINEAR_USERS" => execute_envelope(json!({ "nodes": [ { "id": "linear-cap-viewer", "name": "Cap Viewer", "email": "cap@linear.test" } ] })), "LINEAR_LIST_LINEAR_ISSUES" => execute_envelope(json!({ "nodes": issues, "pageInfo": { "hasNextPage": false, "endCursor": null } })), _ => execute_envelope(json!({})), }; Json(resp) } }), ) } #[tokio::test] async fn linear_sync_max_items_caps_ingest_to_exact_count() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One page returns 5 valid Linear issues; the cap is 2. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(linear_cap_router( linear_cap_issues(5), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "linear".to_string(), connection_id: Some("conn-linear-cap".to_string()), usage: Default::default(), // Mock returns 5 issues; cap is 2 — only 2 must be ingested. max_items: Some(2), sync_depth_days: None, }; let outcome = LinearProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("linear cap sync"); assert_eq!( outcome.items_ingested, 2, "max_items=2 must cap Linear ingest to exactly 2 even though the page held 5" ); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // Linear sync_depth_days enforcement (via the shared orchestrator) // // Linear applies the depth window client-side (RFC3339 `updatedAt`). The mock // returns 2 recent issues (far-future) + 3 ancient (year-2000) in descending // order; with sync_depth_days=7 only the 2 recent issues persist — the // orchestrator truncates the page at the first item below the floor. // ───────────────────────────────────────────────────────────────────────── /// Build `recent` + `old` Linear issues in descending `updatedAt` order. fn linear_depth_issues(recent: usize, old: usize) -> Vec { let mut issues = Vec::new(); for i in 0..recent { issues.push(json!({ "id": format!("linear-recent-{i:04}"), "identifier": format!("ENG-R{i}"), "title": format!("Recent {i}"), "updatedAt": format!("2099-12-{:02}T10:00:00.000Z", 28 - i), "url": format!("https://linear.app/cap/issue/ENG-R{i}"), "description": "recent", })); } for i in 0..old { issues.push(json!({ "id": format!("linear-old-{i:04}"), "identifier": format!("ENG-O{i}"), "title": format!("Old {i}"), "updatedAt": format!("2000-01-{:02}T10:00:00.000Z", 3 - i), "url": format!("https://linear.app/cap/issue/ENG-O{i}"), "description": "old", })); } issues } #[tokio::test] async fn linear_sync_depth_days_filters_old_issues() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One page: 2 recent + 3 ancient. With a 7-day window only the 2 recent // issues must be ingested. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(linear_cap_router( linear_depth_issues(2, 3), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "linear".to_string(), connection_id: Some("conn-linear-depth".to_string()), usage: Default::default(), max_items: None, sync_depth_days: Some(7), }; let outcome = LinearProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("linear depth sync"); assert_eq!( outcome.items_ingested, 2, "sync_depth_days=7 must drop the 3 year-2000 issues and keep the 2 recent ones" ); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // ClickUp cap enforcement // // Proves that max_items=N caps ClickUp ingestion to exactly N tasks even // when a single CLICKUP_GET_FILTERED_TEAM_TASKS page returns more than N. // // Tool sequence: // 1. CLICKUP_GET_AUTHORIZED_USER → user numeric id // 2. CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES → one workspace // 3. CLICKUP_GET_FILTERED_TEAM_TASKS → tasks page // // The tasks page returns 5 items; with max_items=2 only 2 must be // persisted. Because INITIAL_PAGE_SIZE=100 for ConnectionCreated and we // return only 5 tasks (< 100), the short-page guard stops the loop so no // second page is requested, matching the cap path cleanly. // ───────────────────────────────────────────────────────────────────────── /// Build M distinct, valid ClickUp task objects. fn clickup_cap_tasks(m: usize) -> Vec { // date_updated must be large enough to sort lexicographically correctly // but recent enough not to trip any depth filter. We use ms-since-epoch // strings in the vicinity of mid-2026 (≈1780000000000 ms). (1..=m) .map(|i| { json!({ "id": format!("clickup-cap-task-{i:04}"), "name": format!("Cap task {i}"), "text_content": format!("Content for cap task {i}."), "status": { "status": "open" }, "date_updated": format!("{}", 1_780_000_000_000_u64 + i as u64 * 1000), "url": format!("https://app.clickup.com/t/clickup-cap-task-{i:04}") }) }) .collect() } /// Loopback router for the ClickUp cap test. fn clickup_cap_router(tasks: Vec, requests: Arc>>) -> Router { Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let tasks = tasks.clone(); let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let resp = match tool { "CLICKUP_GET_AUTHORIZED_USER" => execute_envelope(json!({ "user": { "id": 42, "username": "cap-user", "email": "cap@clickup.test", "profilePicture": null } })), "CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES" => execute_envelope(json!({ "teams": [ { "id": "ws-cap-01", "name": "Cap Workspace" } ] })), "CLICKUP_GET_FILTERED_TEAM_TASKS" => execute_envelope(json!({ "tasks": tasks })), _ => execute_envelope(json!({})), }; Json(resp) } }), ) } #[tokio::test] async fn clickup_sync_max_items_caps_ingest_to_exact_count() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One workspace, one page returning 5 valid tasks; the cap is 2. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(clickup_cap_router( clickup_cap_tasks(5), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "clickup".to_string(), connection_id: Some("conn-clickup-cap".to_string()), usage: Default::default(), // Mock returns 5 tasks; cap is 2 — only 2 must be ingested. max_items: Some(2), sync_depth_days: None, }; let outcome = ClickUpProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("clickup cap sync"); assert_eq!( outcome.items_ingested, 2, "max_items=2 must cap ClickUp ingest to exactly 2 even though the page held 5" ); server.abort(); } // ───────────────────────────────────────────────────────────────────────── // ClickUp sync_depth_days enforcement (via the shared orchestrator) // // ClickUp's `date_updated` is an epoch-millis string, so the orchestrator's // depth floor must be epoch-ms too (ClickUpSource::depth_floor override). The // mock returns 2 recent tasks (year-2030 epoch-ms) + 3 ancient (year-2001) in // descending order; with sync_depth_days=7 only the 2 recent tasks persist. // ───────────────────────────────────────────────────────────────────────── /// Build `recent` + `old` ClickUp tasks in descending `date_updated` order, /// using epoch-millis strings so the epoch-ms depth floor compares correctly. fn clickup_depth_tasks(recent: usize, old: usize) -> Vec { let mut tasks = Vec::new(); for i in 0..recent { tasks.push(json!({ "id": format!("clickup-recent-{i:04}"), "name": format!("Recent {i}"), "text_content": "recent", "status": { "status": "open" }, "date_updated": format!("{}", 1_900_000_000_000_u64 + (recent - i) as u64), "url": format!("https://app.clickup.com/t/clickup-recent-{i:04}") })); } for i in 0..old { tasks.push(json!({ "id": format!("clickup-old-{i:04}"), "name": format!("Old {i}"), "text_content": "old", "status": { "status": "open" }, "date_updated": format!("{}", 1_000_000_000_000_u64 + (old - i) as u64), "url": format!("https://app.clickup.com/t/clickup-old-{i:04}") })); } tasks } #[tokio::test] async fn clickup_sync_depth_days_filters_old_tasks() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One workspace, one page: 2 recent + 3 ancient. With a 7-day window only // the 2 recent tasks must be ingested. let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let (base, server) = loopback_router(clickup_cap_router( clickup_depth_tasks(2, 3), Arc::clone(&requests), )) .await; let mut config = config_in(&tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); let ctx = ProviderContext { config: Arc::new(config), toolkit: "clickup".to_string(), connection_id: Some("conn-clickup-depth".to_string()), usage: Default::default(), max_items: None, sync_depth_days: Some(7), }; let outcome = ClickUpProvider::new() .sync(&ctx, SyncReason::ConnectionCreated) .await .expect("clickup depth sync"); assert_eq!( outcome.items_ingested, 2, "sync_depth_days=7 must drop the 3 year-2001 tasks and keep the 2 recent ones" ); server.abort(); } fn walk_files(root: &Path) -> Vec { let mut out = Vec::new(); if !root.exists() { return out; } let mut stack = vec![root.to_path_buf()]; while let Some(path) = stack.pop() { let entries = match std::fs::read_dir(&path) { Ok(entries) => entries, Err(_) => continue, }; for entry in entries.flatten() { let child = entry.path(); if child.is_dir() { stack.push(child); } else { out.push(child); } } } out } // ───────────────────────────────────────────────────────────────────────── // Sync-cap enforcement: max_items and sync_depth_days // // These verify the user-facing promise of the per-source sync settings: // - max_items=N ingests AT MOST N items, even when a single API page // returns more (precise mid-page cap, not just a page cap). // - sync_depth_days injects an `updated:>{floor}` date filter into the // GitHub search query so only recent items are requested. // The cap logic is shared verbatim across the gmail/notion/linear/clickup // providers, so GitHub stands in for all of them here. // ───────────────────────────────────────────────────────────────────────── /// Build `n` distinct, valid GitHub search items (each with an id + updated_at). fn github_issue_items(n: usize) -> Vec { (1..=n) .map(|i| { json!({ "id": 3000 + i, "title": format!("Cap issue {i}"), "body": "cap enforcement body", "state": "open", "updated_at": format!("2026-05-{:02}T10:00:00Z", 10 + i), "html_url": format!("https://github.com/tinyhumansai/openhuman/issues/{i}") }) }) .collect() } /// Loopback router that answers the two GitHub sync tools. The search tool /// always returns `items` (one page), and every request body is captured. fn github_cap_router(items: Vec, requests: Arc>>) -> Router { Router::new().route( "/agent-integrations/composio/execute", any(move |Json(body): Json| { let items = items.clone(); let requests = Arc::clone(&requests); async move { requests.lock().unwrap().push(body.clone()); let tool = body.get("tool").and_then(Value::as_str).unwrap_or(""); let resp = match tool { "GITHUB_GET_THE_AUTHENTICATED_USER" => execute_envelope(json!({ "login": "octo-cap", "html_url": "https://github.com/octo-cap" })), "GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS" => execute_envelope(json!({ "items": items, "total_count": items.len() })), _ => execute_envelope(json!({})), }; Json(resp) } }), ) } async fn github_cap_context( tmp: &TempDir, items: Vec, requests: Arc>>, ) -> (Config, tokio::task::JoinHandle<()>) { let (base, server) = loopback_router(github_cap_router(items, requests)).await; let mut config = config_in(tmp); config.api_url = Some(base); persist_config(&config).await; store_session(&config); memory_global::init(config.workspace_dir.clone()).expect("init global memory"); (config, server) } fn github_ctx( config: &Config, max_items: Option, sync_depth_days: Option, ) -> ProviderContext { ProviderContext { config: Arc::new(config.clone()), toolkit: "github".to_string(), connection_id: Some("conn-cap".to_string()), usage: Default::default(), max_items, sync_depth_days, } } #[tokio::test] async fn github_sync_max_items_caps_ingest_to_exact_count() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // One page returns 5 valid items; the cap is 2. let requests = Arc::new(Mutex::new(Vec::new())); let (config, server) = github_cap_context(&tmp, github_issue_items(5), Arc::clone(&requests)).await; let outcome = GitHubProvider::new() .sync( &github_ctx(&config, Some(2), None), SyncReason::ConnectionCreated, ) .await .expect("github sync"); assert_eq!( outcome.items_ingested, 2, "max_items=2 must cap ingest to exactly 2 even though the page held 5" ); server.abort(); } #[tokio::test] async fn github_sync_without_max_items_ingests_full_page() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); // Control: no cap → every valid item on the page is ingested. let requests = Arc::new(Mutex::new(Vec::new())); let (config, server) = github_cap_context(&tmp, github_issue_items(5), Arc::clone(&requests)).await; let outcome = GitHubProvider::new() .sync( &github_ctx(&config, None, None), SyncReason::ConnectionCreated, ) .await .expect("github sync"); assert_eq!( outcome.items_ingested, 5, "with no max_items cap, all 5 page items must be ingested" ); server.abort(); } #[tokio::test] async fn github_sync_depth_days_injects_updated_floor_into_query() { let _guard = env_lock(); let tmp = TempDir::new().expect("tempdir"); let _workspace = EnvGuard::set_path("OPENHUMAN_WORKSPACE", tmp.path()); let _home = EnvGuard::set_path("HOME", tmp.path()); let _backend = EnvGuard::unset("BACKEND_URL"); let requests = Arc::new(Mutex::new(Vec::new())); let (config, server) = github_cap_context(&tmp, github_issue_items(1), Arc::clone(&requests)).await; GitHubProvider::new() .sync( &github_ctx(&config, None, Some(7)), SyncReason::ConnectionCreated, ) .await .expect("github sync"); // The search request must carry an `updated:>{date}` floor derived from the // 7-day window, proving sync_depth_days actually narrows what is fetched. let reqs = requests.lock().unwrap(); let search = reqs .iter() .find(|b| { b.get("tool").and_then(Value::as_str) == Some("GITHUB_SEARCH_ISSUES_AND_PULL_REQUESTS") }) .expect("a search request was issued"); let q = search .get("arguments") .and_then(|a| a.get("q")) .and_then(Value::as_str) .unwrap_or(""); assert!( q.contains("updated:>"), "sync_depth_days=7 must inject an `updated:>` date floor, got query: {q}" ); server.abort(); }