feat(sync): add Composio whatsapp memory-sync pipeline - #108
Conversation
Add `WhatsappSyncPipeline`, a message-shaped Composio sync pipeline for the `whatsapp` toolkit, modeled on `GmailSyncPipeline` (flat, cursor-paged, incremental by timestamp). It implements both `SyncPipeline` and `IncrementalSource`, fetching via the `WHATSAPP_FETCH_MESSAGES` action and mapping each message to a memory document. Dedupe uses the provider's stable per-message id: `document_id = "whatsapp:<message_id>"` with `metadata.taint = "external_sync"`, so re-syncing upserts rather than duplicates (no per-run request ids as keys). Debug logging is content-free (counts and toolkit only — never bodies or phone numbers). Registered through `providers/mod.rs`, `composio/mod.rs`, and `memory/sync/mod.rs` alongside the existing six pipelines. Tests: toolkit()/action()/id(), extract_page() over a sample Composio payload (items + pagination cursor), stable document_id from document(), and cursor normalization. `cargo test --features sync` green (1271 lib tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds an incremental Composio WhatsApp synchronization pipeline with cursor pagination, timestamp normalization, stable message IDs, document mapping, tests, and public re-exports. ChangesWhatsApp synchronization
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant SyncPipeline
participant WhatsappSyncPipeline
participant ComposioClient
participant SkillDocument
SyncPipeline->>WhatsappSyncPipeline: tick()
WhatsappSyncPipeline->>ComposioClient: fetch messages using cursor or after
ComposioClient-->>WhatsappSyncPipeline: message page and next cursor
WhatsappSyncPipeline->>SkillDocument: create stable documents from wamid
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/memory/sync/composio/providers/whatsapp.rs`:
- Around line 264-383: Move the inline #[cfg(test)] tests module from the
WhatsApp implementation into a sibling whatsapp_tests.rs file. Preserve all
existing test cases, helper definitions, imports, and behavior, and wire the
sibling module into the implementation using the project’s established per-file
test-module pattern.
- Around line 111-129: Update the arguments method’s persisted state.cursor
branch to avoid cursor_to_seconds(cursor).unwrap_or_default(); when parsing
fails, use the same bounded config.sync.budget.sync_depth_days fallback as the
no-cursor branch, while preserving the parsed cursor behavior and page-token
handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b610ba1b-5e84-45fe-a047-8c8ed11aed13
📒 Files selected for processing (4)
src/memory/sync/composio/mod.rssrc/memory/sync/composio/providers/mod.rssrc/memory/sync/composio/providers/whatsapp.rssrc/memory/sync/mod.rs
| fn arguments( | ||
| &self, | ||
| _scope: &SyncScope, | ||
| config: &MemoryConfig, | ||
| state: &SyncState, | ||
| page: Option<&str>, | ||
| ) -> Value { | ||
| let mut arguments = serde_json::json!({ "limit": self.page_size }); | ||
| if let Some(token) = page { | ||
| arguments["cursor"] = serde_json::json!(token); | ||
| } else if let Some(cursor) = state.cursor.as_deref() { | ||
| arguments["after"] = serde_json::json!(cursor_to_seconds(cursor).unwrap_or_default()); | ||
| } else if let Some(days) = config.sync.budget.sync_depth_days { | ||
| arguments["after"] = serde_json::json!((chrono::Utc::now() | ||
| - chrono::Duration::days(days as i64)) | ||
| .timestamp()); | ||
| } | ||
| arguments | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Unparseable cursor silently falls back to epoch 0, not a safe default.
At Line 122, cursor_to_seconds(cursor).unwrap_or_default() yields 0i64 when the persisted state.cursor fails to parse (neither integer nor RFC3339). That sets arguments["after"] = 0, i.e. "fetch every message since 1970," which is the opposite of a safe incremental fallback — it can trigger an unbounded, expensive historical refetch against Composio/WhatsApp on the very run meant to resume incrementally. The adjacent branch (no cursor at all) correctly falls back to config.sync.budget.sync_depth_days; an unparseable cursor should use the same bounded fallback rather than silently defaulting to epoch 0.
🐛 Proposed fix: reuse the depth-days fallback instead of defaulting to epoch 0
- if let Some(token) = page {
- arguments["cursor"] = serde_json::json!(token);
- } else if let Some(cursor) = state.cursor.as_deref() {
- arguments["after"] = serde_json::json!(cursor_to_seconds(cursor).unwrap_or_default());
- } else if let Some(days) = config.sync.budget.sync_depth_days {
- arguments["after"] = serde_json::json!((chrono::Utc::now()
- - chrono::Duration::days(days as i64))
- .timestamp());
- }
+ let depth_fallback = || {
+ config.sync.budget.sync_depth_days.map(|days| {
+ (chrono::Utc::now() - chrono::Duration::days(days as i64)).timestamp()
+ })
+ };
+ if let Some(token) = page {
+ arguments["cursor"] = serde_json::json!(token);
+ } else if let Some(seconds) = state
+ .cursor
+ .as_deref()
+ .and_then(cursor_to_seconds)
+ .or_else(depth_fallback)
+ {
+ arguments["after"] = serde_json::json!(seconds);
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| fn arguments( | |
| &self, | |
| _scope: &SyncScope, | |
| config: &MemoryConfig, | |
| state: &SyncState, | |
| page: Option<&str>, | |
| ) -> Value { | |
| let mut arguments = serde_json::json!({ "limit": self.page_size }); | |
| if let Some(token) = page { | |
| arguments["cursor"] = serde_json::json!(token); | |
| } else if let Some(cursor) = state.cursor.as_deref() { | |
| arguments["after"] = serde_json::json!(cursor_to_seconds(cursor).unwrap_or_default()); | |
| } else if let Some(days) = config.sync.budget.sync_depth_days { | |
| arguments["after"] = serde_json::json!((chrono::Utc::now() | |
| - chrono::Duration::days(days as i64)) | |
| .timestamp()); | |
| } | |
| arguments | |
| } | |
| fn arguments( | |
| &self, | |
| _scope: &SyncScope, | |
| config: &MemoryConfig, | |
| state: &SyncState, | |
| page: Option<&str>, | |
| ) -> Value { | |
| let mut arguments = serde_json::json!({ "limit": self.page_size }); | |
| let depth_fallback = || { | |
| config.sync.budget.sync_depth_days.map(|days| { | |
| (chrono::Utc::now() - chrono::Duration::days(days as i64)).timestamp() | |
| }) | |
| }; | |
| if let Some(token) = page { | |
| arguments["cursor"] = serde_json::json!(token); | |
| } else if let Some(seconds) = state | |
| .cursor | |
| .as_deref() | |
| .and_then(cursor_to_seconds) | |
| .or_else(depth_fallback) | |
| { | |
| arguments["after"] = serde_json::json!(seconds); | |
| } | |
| arguments | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/memory/sync/composio/providers/whatsapp.rs` around lines 111 - 129,
Update the arguments method’s persisted state.cursor branch to avoid
cursor_to_seconds(cursor).unwrap_or_default(); when parsing fails, use the same
bounded config.sync.budget.sync_depth_days fallback as the no-cursor branch,
while preserving the parsed cursor behavior and page-token handling.
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use crate::memory::config::{ComposioMode, ComposioSyncConfig}; | ||
|
|
||
| fn pipeline() -> WhatsappSyncPipeline { | ||
| let config = ComposioSyncConfig { | ||
| mode: ComposioMode::Direct, | ||
| base_url: "https://backend.composio.dev".into(), | ||
| api_key: None, | ||
| bearer_token: None, | ||
| entity_id: None, | ||
| }; | ||
| WhatsappSyncPipeline::new(ComposioClient::new(config), "conn-1") | ||
| } | ||
|
|
||
| struct NoopExecutor; | ||
|
|
||
| #[async_trait] | ||
| impl ActionExecutor for NoopExecutor { | ||
| async fn execute( | ||
| &self, | ||
| _action: &str, | ||
| _arguments: Value, | ||
| _connection_id: Option<&str>, | ||
| ) -> anyhow::Result<crate::memory::sync::composio::ExecuteResponse> { | ||
| anyhow::bail!("no execution expected in this test") | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn advertises_toolkit_and_fetch_action() { | ||
| let pipeline = pipeline(); | ||
| assert_eq!(pipeline.toolkit(), "whatsapp"); | ||
| assert_eq!(pipeline.action(), "WHATSAPP_FETCH_MESSAGES"); | ||
| assert_eq!(pipeline.id(), "composio:whatsapp"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn extract_page_reads_messages_and_cursor() { | ||
| let pipeline = pipeline(); | ||
| let payload = serde_json::json!({ | ||
| "data": { | ||
| "messages": [ | ||
| {"id": "wamid.AAA", "timestamp": "1721000000", "from": "chat-1"}, | ||
| {"id": "wamid.BBB", "timestamp": "1721000100", "from": "chat-1"} | ||
| ], | ||
| "paging": { "cursors": { "after": "CURSOR-2" } } | ||
| } | ||
| }); | ||
|
|
||
| let fetched = pipeline.extract_page(&payload, None); | ||
|
|
||
| assert_eq!(fetched.items.len(), 2); | ||
| assert_eq!(fetched.items[0]["id"], "wamid.AAA"); | ||
| assert_eq!(fetched.next.as_deref(), Some("CURSOR-2")); | ||
| assert_eq!( | ||
| pipeline.dedup_key(&fetched.items[0]).as_deref(), | ||
| Some("wamid.AAA") | ||
| ); | ||
| assert_eq!( | ||
| pipeline.sort_cursor(&fetched.items[1]).as_deref(), | ||
| Some("1721000100") | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn extract_page_defaults_when_envelope_is_empty() { | ||
| let pipeline = pipeline(); | ||
| let fetched = pipeline.extract_page(&serde_json::json!({}), None); | ||
| assert!(fetched.items.is_empty()); | ||
| assert!(fetched.next.is_none()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn document_uses_stable_message_id_as_key() { | ||
| let pipeline = pipeline(); | ||
| let raw = serde_json::json!({ | ||
| "id": "wamid.AAA", | ||
| "timestamp": "1721000000", | ||
| "from": "chat-1", | ||
| "text": {"body": "hello"} | ||
| }); | ||
| let mut state = SyncState::new("whatsapp", "conn-1"); | ||
| let item = SyncItem { | ||
| dedup_key: "wamid.AAA".into(), | ||
| sort_cursor: Some("1721000000".into()), | ||
| raw: raw.clone(), | ||
| }; | ||
|
|
||
| let doc = pipeline | ||
| .document( | ||
| &SyncScope::flat(), | ||
| "conn-1", | ||
| item, | ||
| &NoopExecutor, | ||
| &mut state, | ||
| ) | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| // Stable key: identical across runs, so upserts dedupe. | ||
| assert_eq!(doc.document_id, "whatsapp:wamid.AAA"); | ||
| assert_eq!(doc.toolkit, "whatsapp"); | ||
| assert_eq!(doc.namespace_skill_id, "whatsapp"); | ||
| assert_eq!(doc.metadata["taint"], "external_sync"); | ||
| assert_eq!(doc.title, "WhatsApp chat chat-1"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn cursor_to_seconds_handles_millis_seconds_and_rfc3339() { | ||
| assert_eq!(cursor_to_seconds("1721000000000"), Some(1721000000)); | ||
| assert_eq!(cursor_to_seconds("1721000000"), Some(1721000000)); | ||
| let expected = chrono::DateTime::parse_from_rfc3339("2024-07-15T00:00:00Z") | ||
| .unwrap() | ||
| .timestamp(); | ||
| assert_eq!(cursor_to_seconds("2024-07-15T00:00:00Z"), Some(expected)); | ||
| assert_eq!(cursor_to_seconds("not-a-timestamp"), None); | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Tests should live in a sibling whatsapp_tests.rs, not inline.
The #[cfg(test)] mod tests block is embedded directly in the implementation file. As per path instructions, src/**/*.rs should "Keep tests in per-file <name>_tests.rs siblings, such as store.rs and store_tests.rs, rather than mixing tests into implementation files."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/memory/sync/composio/providers/whatsapp.rs` around lines 264 - 383, Move
the inline #[cfg(test)] tests module from the WhatsApp implementation into a
sibling whatsapp_tests.rs file. Preserve all existing test cases, helper
definitions, imports, and behavior, and wire the sibling module into the
implementation using the project’s established per-file test-module pattern.
Source: Path instructions
Summary
Adds
WhatsappSyncPipeline, a Composio memory-sync pipeline for thewhatsapptoolkit, closing the tinycortex-side gap where connecting WhatsApp reportsACTIVEbut sync fails withtinycortex sync does not support toolkit 'whatsapp'.GmailSyncPipeline(src/memory/sync/composio/gmail.rs) — one fetch action, cursor-paged, incremental by message timestamp. WhatsApp's fetch action returns messages across chats in a single stream, so no per-container scope pass (unlike Slack) is needed.SyncPipelineandIncrementalSource;toolkit()→"whatsapp",action()→WHATSAPP_FETCH_MESSAGES. Uses the sharedproviders/common.rsdocument()helper like the other providers.wamid) →document_id = "whatsapp:<message_id>",metadata.taint = "external_sync". Re-syncing upserts rather than duplicates; per-run request ids are never used as the key (the defect class fixed in fix(memory): use stable document_id as sync upsert key (fixes #4947 Bug 2 secret-guard sync failure) openhuman#4953).providers/mod.rs,composio/mod.rs, andmemory/sync/mod.rsexactly like the existing six pipelines.Reference followed
GmailSyncPipelinefor the overall shape;LinearSyncPipelinefor theproviders/common.rshelper usage and module placement.Tests
Unit tests in
whatsapp.rscover:toolkit()/action()/id(),extract_page()over a sample Composio JSON payload (message array + pagination cursor + empty-envelope fallback), a stabledocument_idfromdocument(), and cursor normalization (millis/seconds/RFC3339). Run:cargo test --features sync— green (1271 lib tests + integration suites).cargo fmt --all --check— clean.Scope
Part of #80 (tinycortex pipeline body; openhuman wiring is the follow-up step). The openhuman-side changes — bumping the
vendor/tinycortexsubmodule pointer, adding thesync.rs:344selector arm, and registering theComposioProvidersomemory_sources.supported_toolkitsadvertises the slug — are a separate follow-up and are not included here.🤖 Generated with Claude Code
Summary by CodeRabbit