From 487b10523f4780109590ff2eeb70d906210f0f42 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Mon, 8 Jun 2026 22:06:27 +0200 Subject: [PATCH] fix(integration-events): dedupe thread-parent across messages+threads trees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Slack thread PARENT materializes under BOTH the flat messages/ record AND the threads/ root (thread_ts == parent ts) — two distinct files with distinct revisions, one logical message. slackLogicalChangeFingerprint keyed the threads-root copy as `...:thread::...` and the messages copy as `...:message::...`, so the two copies produced different injection-dedupe keys and the same message was injected twice (and re-injected as each tree's record was re-committed). Operator observed the same message echoing into the channel repeatedly (one msg logged 59x received / 3x injected). Fix: collapse the thread ROOT (no reply segment) to the message identity so both materializations share one dedupe key and inject once. Thread REPLIES keep their distinct `:reply:` identity. Regression test added; 74/74 bridge tests pass. Note: an agent still receives ONE copy of its own outbound post (mirror reads it back down); fully suppressing self-echo needs a self-bot-user signal — follow-up. Co-Authored-By: Claude Opus 4.8 --- .../integration-event-bridge.test.ts | 57 +++++++++++++++++++ src/main/integration-event-bridge.ts | 7 ++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/main/__tests__/integration-event-bridge.test.ts b/src/main/__tests__/integration-event-bridge.test.ts index 0188938a..8b5acbc9 100644 --- a/src/main/__tests__/integration-event-bridge.test.ts +++ b/src/main/__tests__/integration-event-bridge.test.ts @@ -795,6 +795,63 @@ test('slack raw-id and slug alias paths with distinct revisions inject once per assert.deepEqual(harness.sent.map((message) => message.input.to), ['alice', 'alice', 'alice']) }) +test('slack thread parent materialized under both messages and threads trees injects once', async () => { + const harness = makeHarness(['alice'], { + readFileResponse: (_workspaceId, path) => ({ + path, + revision: 'rev-context', + contentType: 'application/json', + content: JSON.stringify({ provider: 'slack', text: 'thread parent message' }), + encoding: 'utf-8' + }) + }) + + // Production mounts messages and threads as separate roots, so the dedupe + // path-tail is identical for both copies — only the logical fingerprint + // differentiates them. + await withMockedNow('2026-06-05T14:00:00.000Z', async () => { + await harness.bridge.reconcile('project-1', [ + integration({ + provider: 'slack', + integrationId: 'slack-1', + mountPaths: [ + '/slack/channels/C123ABC__proj-cloud/messages', + '/slack/channels/C123ABC__proj-cloud/threads' + ], + downloadHistoricalData: false, + scope: { notifyAgents: ['alice'] } + }) + ]) + }) + + // A thread PARENT (thread_ts == parent ts) materializes as BOTH the flat + // messages/ record and the threads/ root, each a distinct file with a + // distinct revision. They are one logical message and must inject once. + await harness.emit(changeEvent( + '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json', + 'slack', + { digest: 'revision:messages-tree' } + )) + await waitForSent(harness, 1) + + await harness.emit(changeEvent( + '/slack/channels/C123ABC__proj-cloud/threads/1780668000_000000/meta.json', + 'slack', + { digest: 'revision:threads-tree' } + )) + await waitForDropped('project-1', 1) + assert.equal(harness.sent.length, 1) + + // A reply inside that thread is a distinct logical message and still injects. + await harness.emit(changeEvent( + '/slack/channels/C123ABC__proj-cloud/threads/1780668000_000000/replies/1780668060_000000.json', + 'slack', + { digest: 'revision:reply-1' } + )) + await waitForSent(harness, 2) + assert.deepEqual(harness.sent.map((message) => message.input.to), ['alice', 'alice']) +}) + test('slack raw-id and slug alias duplicates suppress when one context read is sparse', async () => { const harness = makeHarness(['alice'], { readFileResponse: (_workspaceId, path) => { diff --git a/src/main/integration-event-bridge.ts b/src/main/integration-event-bridge.ts index ef15b021..cbe823e3 100644 --- a/src/main/integration-event-bridge.ts +++ b/src/main/integration-event-bridge.ts @@ -1400,8 +1400,13 @@ function slackLogicalChangeFingerprint(event: ChangeEvent): string | null { const suffix = segments.slice(replyIndex + 2).join('/') return `slack:${scopeKind}:${scopeValue}:thread:${thread}:reply:${segments[replyIndex + 1]}:${suffix}` } + // The thread ROOT (no reply segment) is the same logical Slack message as + // the channel's top-level `messages//...` record (thread_ts == + // parent ts). A thread parent therefore materializes under BOTH trees; + // collapse the root to the message identity so the dual materialization + // dedupes to a single injection instead of one per tree. const suffix = segments.slice(threadIndex + 2).join('/') - return `slack:${scopeKind}:${scopeValue}:thread:${thread}:${suffix}` + return `slack:${scopeKind}:${scopeValue}:message:${thread}:${suffix}` } return null