diff --git a/src/main/__tests__/integration-event-bridge.test.ts b/src/main/__tests__/integration-event-bridge.test.ts index 806bf85..38d3627 100644 --- a/src/main/__tests__/integration-event-bridge.test.ts +++ b/src/main/__tests__/integration-event-bridge.test.ts @@ -160,6 +160,7 @@ function makeHarness( } readFileFailuresBeforeSuccess?: number failReadFile?: boolean + readFileError?: Error sendDelayMs?: number onSendStart?: (activeSends: number) => void waitForDeliveryNeverSettles?: boolean @@ -205,6 +206,7 @@ function makeHarness( if (options.readFileFailuresBeforeSuccess && readFileAttempts <= options.readFileFailuresBeforeSuccess) { throw new Error('remote file not ready') } + if (options.readFileError) throw options.readFileError if (options.failReadFile) throw new Error('remote file not ready') return options.readFileResponse?.(workspaceId, path) ?? { path, @@ -606,25 +608,16 @@ test('integration events watch selected relayfile mount paths', async () => { assert.deepEqual(harness.subscribeCalls[0].globs, [ '/slack/channels/C123ABC/**', - '/slack/channels/C123ABC__proj-cloud/**', - '/slack/channels/D*/**', - '/slack/dms/*/**', - '/slack/users/*/messages/**' + '/slack/channels/C123ABC__proj-cloud/**' ]) assert.deepEqual(harness.subscribeCalls[0].options?.pathScope, [ '/slack/channels/C123ABC/**', - '/slack/channels/C123ABC__proj-cloud/**', - '/slack/channels/D*/**', - '/slack/dms/*/**', - '/slack/users/*/messages/**' + '/slack/channels/C123ABC__proj-cloud/**' ]) assert.equal(harness.subscribeCalls[0].options?.from, 'legacy') assert.deepEqual(integrationSubscriptionSummaries([slackIntegration])[0].watches, [ '.integrations/slack/channels/C123ABC/**', - '.integrations/slack/channels/C123ABC__proj-cloud/**', - '.integrations/slack/channels/D*/**', - '.integrations/slack/dms/*/**', - '.integrations/slack/users/*/messages/**' + '.integrations/slack/channels/C123ABC__proj-cloud/**' ]) const selectedPath = '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json' @@ -669,8 +662,7 @@ test('integration events watch selected relayfile mount paths', async () => { assert.deepEqual(harness.sent, []) await harness.emit(changeEvent('/slack/channels/D123ABC/messages/1780668180_000000/meta.json', 'slack')) - await waitForSent(harness, 1) - assert.deepEqual(harness.sent.map((message) => message.input.to), ['alice']) + assert.deepEqual(harness.sent, []) }) test('slack raw-id and slug alias paths with distinct revisions inject once per logical message', async () => { @@ -800,7 +792,7 @@ test('slack raw-id and slug alias duplicates suppress when one context read is s assert.match(harness.sent[0].input.text, /Message:\nreadable Slack message/u) }) -test('slack edits after a blind alias claim still inject once the content changes', async () => { +test('slack raw-id event resolves context through mounted slug alias', async () => { let messageText = 'original Slack message' const harness = makeHarness(['alice'], { readFileResponse: (_workspaceId, path) => { @@ -827,8 +819,8 @@ test('slack edits after a blind alias claim still inject once the content change ]) }) - // Raw-id copy first: every targeted read fails and the expanded event only - // carries the sparse relayfile pointer, so the injection is blind. + // Raw-id copy first: the raw targeted read fails, then the bridge retries the + // selected mounted slug alias so the injection has usable context. await harness.emit({ ...changeEvent( '/slack/channels/C123ABC/messages/1780668000_000000/meta.json', @@ -846,10 +838,21 @@ test('slack edits after a blind alias claim still inject once the content change } as ChangeEvent) await waitForSent(harness, 1, 2_500) assert.equal(harness.sent.length, 1) - assert.match(harness.sent[0].input.text, /Message: unavailable; targeted context read did not return content\./u) + assert.match(harness.sent[0].input.text, /Message:\noriginal Slack message/u) + assert.match(harness.sent[0].input.text, /Path: \.integrations\/slack\/channels\/C123ABC__proj-cloud\/messages\/1780668000_000000\/meta\.json/u) + assert.deepEqual(harness.readFileCalls.slice(0, 2), [ + { + workspaceId: 'workspace-id', + path: '/slack/channels/C123ABC/messages/1780668000_000000/meta.json' + }, + { + workspaceId: 'workspace-id', + path: '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json' + } + ]) - // The slug alias copy of the same record carries content: suppressed as a - // duplicate, but the claim learns the content hash. + // The slug alias copy of the same record is now a duplicate of the + // content-bearing raw delivery. await harness.emit(changeEvent( '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json', 'slack', @@ -1052,7 +1055,7 @@ test('historical download subscriptions can receive older remote events', async assert.deepEqual(harness.sent.map((message) => message.input.to), ['alice']) }) -test('slack direct message event scope can be disabled', async () => { +test('slack direct message event scope is opt-in', async () => { const harness = makeHarness() const slackIntegration = integration({ provider: 'slack', @@ -1173,11 +1176,17 @@ test('slack context falls back to expanded event data when targeted remote previ assert.match(harness.sent[0].input.text, /Slack message event/u) assert.match(harness.sent[0].input.text, /Author: Khaliq/u) assert.match(harness.sent[0].input.text, /Message:\nexpanded Slack context/u) - assert.equal(harness.readFileCalls.length, 4) - assert.deepEqual(harness.readFileCalls[0], { - workspaceId: 'workspace-id', - path: messagePath - }) + assert.equal(harness.readFileCalls.length, 8) + assert.deepEqual(harness.readFileCalls.slice(0, 2), [ + { + workspaceId: 'workspace-id', + path: messagePath + }, + { + workspaceId: 'workspace-id', + path: '/slack/channels/C123ABC/messages/1780668000_000000/meta.json' + } + ]) assert.equal((harness.sent[0].input.data?.contextPreview as { kind?: string } | undefined)?.kind, 'text') assert.equal((harness.sent[0].input.data?.contextPreview as { content?: string } | undefined)?.content, undefined) }) @@ -1193,7 +1202,7 @@ test('slack context retries targeted remote preview before falling back to spars integrationId: 'slack-1', mountPaths: ['/slack/channels/C123ABC__proj-cloud'], downloadHistoricalData: false, - scope: { notifyAgents: ['alice'] } + scope: { listenDms: true, notifyAgents: ['alice'] } }) ]) }) @@ -1217,6 +1226,45 @@ test('slack context retries targeted remote preview before falling back to spars assert.doesNotMatch(harness.sent[0].input.text, /"deleted": false/u) }) +test('slack context stops targeted remote preview retries on auth failures', async () => { + const error = new Error('http 403 forbidden') as Error & { status: number } + error.status = 403 + const harness = makeHarness(['alice'], { readFileError: error }) + const messagePath = '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json' + + 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'], + downloadHistoricalData: false, + scope: { notifyAgents: ['alice'] } + }) + ]) + }) + + await harness.emit({ + ...changeEvent(messagePath, 'slack'), + expand: async () => ({ + level: 'full', + path: messagePath, + data: { + text: 'expanded Slack context' + } + }) + } as ChangeEvent) + await waitForSent(harness, 1, 2_500) + + assert.deepEqual(harness.readFileCalls, [ + { + workspaceId: 'workspace-id', + path: messagePath + } + ]) + assert.match(harness.sent[0].input.text, /Message:\nexpanded Slack context/u) +}) + test('slack context does not inject sparse relayfile pointer fallback as message content', async () => { const harness = makeHarness(['alice'], { failReadFile: true }) const messagePath = '/slack/channels/D123ABC/messages/1780668000_000000/meta.json' @@ -1228,7 +1276,7 @@ test('slack context does not inject sparse relayfile pointer fallback as message integrationId: 'slack-1', mountPaths: ['/slack/channels/C123ABC__proj-cloud'], downloadHistoricalData: false, - scope: { notifyAgents: ['alice'] } + scope: { listenDms: true, notifyAgents: ['alice'] } }) ]) }) @@ -1600,16 +1648,10 @@ test('integration events preserve discovery mount paths', async () => { await harness.bridge.reconcile('project-1', [slackIntegration]) assert.deepEqual(harness.subscribeCalls[0].globs, [ - '/discovery/slack/**', - '/slack/channels/D*/**', - '/slack/dms/*/**', - '/slack/users/*/messages/**' + '/discovery/slack/**' ]) assert.deepEqual(integrationSubscriptionSummaries([slackIntegration])[0].watches, [ - '.integrations/discovery/slack/**', - '.integrations/slack/channels/D*/**', - '.integrations/slack/dms/*/**', - '.integrations/slack/users/*/messages/**' + '.integrations/discovery/slack/**' ]) await harness.emit(changeEvent('/discovery/slack/actions/create-message/.schema.json', 'slack')) diff --git a/src/main/integration-event-bridge.ts b/src/main/integration-event-bridge.ts index 5647427..7f96e3c 100644 --- a/src/main/integration-event-bridge.ts +++ b/src/main/integration-event-bridge.ts @@ -376,6 +376,18 @@ function dedupeStrings(values: string[]): string[] { return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).sort() } +function dedupeStringsInOrder(values: string[]): string[] { + const seen = new Set() + const deduped: string[] = [] + for (const value of values) { + const trimmed = value.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + deduped.push(trimmed) + } + return deduped +} + function sameStringList(left: string[], right: string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]) } @@ -400,7 +412,7 @@ function scopeBooleanDefault(scope: Record, keys: string[], def function slackListenDms(integration: ConnectedIntegration): boolean { if (!isSlackProvider(integration.provider)) return false - return scopeBooleanDefault(integration.scope, ['listenDms', 'listenDirectMessages', 'directMessages'], true) + return scopeBooleanDefault(integration.scope, ['listenDms', 'listenDirectMessages', 'directMessages'], false) } function pathSegments(path: string): string[] { @@ -1708,6 +1720,32 @@ function slackEventContextPath(path: string): boolean { return /^\/slack\/(?:channels|dms|users)\/[^/]+\/(?:messages|threads)\/.+\.json$/u.test(path) } +function slackContextReadCandidatePaths(path: string, specs: SubscriptionSpec[]): string[] { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + if (!slackEventContextPath(normalizedPath)) return [normalizedPath] + + const match = normalizedPath.match(/^\/slack\/channels\/([^/]+)(\/(?:messages|threads)\/.+\.json)$/u) + if (!match?.[1] || !match[2]) return [normalizedPath] + + const currentChannel = match[1] + const channelId = canonicalSlackChannelSegment(currentChannel) + const tail = match[2] + const candidates = [normalizedPath] + + for (const spec of specs) { + for (const mountPath of spec.mountPaths) { + const mountMatch = mountPath.match(/^\/slack\/channels\/([^/]+)(?:\/|$)/u) + const mountedChannel = mountMatch?.[1] + if (!mountedChannel || canonicalSlackChannelSegment(mountedChannel) !== channelId) { + continue + } + candidates.push(`/slack/channels/${mountedChannel}${tail}`) + } + } + + return dedupeStringsInOrder(candidates) +} + function slackScopeLabel(path: string): string | undefined { const segments = pathSegments(path) const channelIndex = segments.indexOf('channels') @@ -1730,8 +1768,9 @@ function formatSlackIntegrationEventMessage( const relayfilePath = eventSummaryValue(resource.path) if (provider !== 'slack' || !relayfilePath || !slackEventContextPath(relayfilePath)) return null - const projectPath = projectIntegrationPathForRelayfilePath(relayfilePath) - const scopeLabel = slackScopeLabel(relayfilePath) + const contextPath = contextPreview?.path || relayfilePath + const projectPath = projectIntegrationPathForRelayfilePath(contextPath) + const scopeLabel = slackScopeLabel(contextPath) const messageText = slackPreviewText(contextPreview) const author = slackPreviewAuthor(contextPreview) const lines = [ @@ -2271,7 +2310,11 @@ export class IntegrationEventBridge { ) } - private async readEventContextPreview(projectId: string, event: ChangeEvent): Promise { + private async readEventContextPreview( + projectId: string, + event: ChangeEvent, + matchedSpecs: SubscriptionSpec[] + ): Promise { if (event.type === 'file.deleted' || event.type === 'relayfile.changed.summary') return undefined const path = eventSummaryValue(event.resource.path) if (!path) return undefined @@ -2281,14 +2324,19 @@ export class IntegrationEventBridge { const readDelays = slackEventContextPath(path) ? [0, ...EVENT_CONTEXT_READ_RETRY_DELAYS_MS] : [0] const handle = await this.getWorkspaceHandle() const client = handle.client() + const candidatePaths = slackEventContextPath(path) + ? slackContextReadCandidatePaths(path, matchedSpecs) + : [path] if (typeof client.readFile === 'function') { for (const [index, delayMs] of readDelays.entries()) { if (delayMs > 0) await delay(delayMs) - try { - return eventContextPreviewFromFile(await client.readFile(handle.workspaceId, path)) - } catch (error) { - readFileError = error - if (index === readDelays.length - 1) break + for (const candidatePath of candidatePaths) { + try { + return eventContextPreviewFromFile(await client.readFile(handle.workspaceId, candidatePath)) + } catch (error) { + readFileError = error + if (isUnauthorizedError(error)) throw error + } } } } @@ -2401,7 +2449,7 @@ export class IntegrationEventBridge { } const eventMetadata = integrationEventMetadata(event) - const contextPreview = await this.readEventContextPreview(projectId, event) + const contextPreview = await this.readEventContextPreview(projectId, event, matchedSpecs) const usesConcreteAgentTargets = uniqueRecipients.every((recipient) => !recipient.startsWith('#')) const canTrackInjectedDelivery = usesConcreteAgentTargets && typeof bridge.sendMessageAndWaitForInjected === 'function' const shouldTrackDedupe = canTrackInjectedDelivery diff --git a/src/main/integration-mounts.test.ts b/src/main/integration-mounts.test.ts index 4f1e406..bd6b4d1 100644 --- a/src/main/integration-mounts.test.ts +++ b/src/main/integration-mounts.test.ts @@ -276,6 +276,24 @@ describe('IntegrationMountManager', () => { }) }) + it('mounts Slack thread context roots in mirror mode', async () => { + const manager = new IntegrationMountManager() + + await manager.ensureMounted([ + { + provider: 'slack', + mountPaths: ['/slack/channels/C123/threads'] + } + ]) + + expect(mock.mountInputs[0]).toMatchObject({ + localDir: '/tmp/pear-home/.agentworkforce/pear/relayfile/workspaces/account-workspace-id/slack/channels/C123/threads', + remotePath: '/slack/channels/C123/threads', + localLayout: 'exact', + syncMode: 'mirror' + }) + }) + it('rejects Slack command roots with traversal segments', async () => { const manager = new IntegrationMountManager() diff --git a/src/main/integrations.test.ts b/src/main/integrations.test.ts index 3fd34f4..73fb8e2 100644 --- a/src/main/integrations.test.ts +++ b/src/main/integrations.test.ts @@ -500,7 +500,9 @@ describe('IntegrationsManager', () => { })).toEqual([ '/discovery/slack', '/slack/channels/C123/messages', + '/slack/channels/C123/threads', '/slack/dms/D123/messages', + '/slack/dms/D123/threads', '/slack/users/U123/messages' ]) }) @@ -534,6 +536,7 @@ describe('IntegrationsManager', () => { expect(message).toContain('create writeback files under .integrations/slack/channels/C123/messages') expect(message).toContain('Writeback command roots are mounted at .integrations/slack/channels/C123/messages') + expect(message).toContain('live thread context roots are mounted at .integrations/slack/channels/C123/threads') expect(message).not.toContain('create writeback files under .integrations/slack/channels/C123, not under discovery') }) @@ -577,6 +580,16 @@ describe('IntegrationsManager', () => { await vi.waitFor(() => { expect(mock.integrationMountManager.ensureMounted).toHaveBeenCalled() }) + expect(mock.integrationMountManager.ensureMounted).toHaveBeenLastCalledWith([ + { + provider: 'slack', + mountPaths: [ + '/discovery/slack', + '/slack/channels/C123/messages', + '/slack/channels/C123/threads' + ] + } + ]) finishMountReconcile() }) diff --git a/src/main/integrations.ts b/src/main/integrations.ts index e42ee5e..0c3fa83 100644 --- a/src/main/integrations.ts +++ b/src/main/integrations.ts @@ -395,12 +395,29 @@ function writebackCommandMountPathsForIntegration(integration: ConnectedIntegrat .filter((mountPath): mountPath is string => !!mountPath) } +function slackThreadContextMountPathsForIntegration(integration: ConnectedIntegration): string[] { + if (toRelayfileProvider(integration.provider) !== 'slack') return [] + return canonicalMountPathsForConnectedIntegration(integration) + .filter(isNarrowHistoricalMountPath) + .flatMap((mountPath) => { + const segments = mountPath.split('/').filter(Boolean) + if (segments[0] !== 'slack') return [] + const collection = segments[1] + if (!['channels', 'dms', 'users'].includes(collection || '')) return [] + if (segments.length === 3) return [`${mountPath}/threads`] + return [] + }) +} + export function localSyncMountPathsForIntegration(integration: ConnectedIntegration): string[] { const discoveryPath = discoveryMountPathForProvider(integration.provider) const historicalPaths = integration.downloadHistoricalData === true ? canonicalMountPathsForConnectedIntegration(integration).filter(isNarrowHistoricalMountPath) : writebackCommandMountPathsForIntegration(integration) - return dedupeStrings([discoveryPath, ...historicalPaths]) + const liveContextPaths = integration.downloadHistoricalData === true + ? [] + : slackThreadContextMountPathsForIntegration(integration) + return dedupeStrings([discoveryPath, ...historicalPaths, ...liveContextPaths]) } function skippedHistoricalMountPathsForIntegration(integration: ConnectedIntegration): string[] { @@ -1829,6 +1846,10 @@ export class IntegrationsManager { const discoveryPath = projectIntegrationPathForRelayfilePath(discoveryMountPathForProvider(integration.provider)) const writebackCommandPaths = writebackCommandMountPathsForIntegration(integration) .map(projectIntegrationPathForRelayfilePath) + const liveContextPaths = integration.downloadHistoricalData === true + ? [] + : slackThreadContextMountPathsForIntegration(integration) + .map(projectIntegrationPathForRelayfilePath) const historyEnabled = integration.downloadHistoricalData === true const writebackPaths = historyEnabled ? mountPaths.join(', ') || 'the configured provider paths' @@ -1849,7 +1870,7 @@ export class IntegrationsManager { ? ` Historical download is enabled, but these provider paths are not locally poll-mounted: ${skippedLocalPaths.join(', ')}. Select fewer or narrower resources to download local history.` : ` Historical provider records are available at ${writebackPaths}.` : writebackPaths - ? ` Local historical provider records are not downloaded. Writeback command roots are mounted at ${writebackPaths}; provider context should be read on demand or through incoming events.` + ? ` Local historical provider records are not broadly downloaded. Writeback command roots are mounted at ${writebackPaths}${liveContextPaths.length > 0 ? `, and live thread context roots are mounted at ${liveContextPaths.join(', ')}` : ''}; provider context should be read on demand or through incoming events.` : ` Local historical provider records are not downloaded. No narrow writeback command roots are mounted; select narrower resources to enable local writeback transport.` lines.push( `- ${integration.provider}: ${scopeSummary}${scopeClause}. Writeback schemas and examples are available at ${discoveryPath}; ${writebackInstruction}. ${historyClause}` diff --git a/src/renderer/src/components/settings/AccountSettings.tsx b/src/renderer/src/components/settings/AccountSettings.tsx index f7942fb..394ba3e 100644 --- a/src/renderer/src/components/settings/AccountSettings.tsx +++ b/src/renderer/src/components/settings/AccountSettings.tsx @@ -82,7 +82,7 @@ function defaultScope(adapter: IntegrationAdapter): Record { return { provider: adapter.provider, scopes: adapter.requiredScopes || [], - ...(canonicalProviderKey(adapter.provider) === 'slack' ? { listenDms: true } : {}) + ...(canonicalProviderKey(adapter.provider) === 'slack' ? { listenDms: false } : {}) } } diff --git a/src/renderer/src/components/settings/ProjectSettings.tsx b/src/renderer/src/components/settings/ProjectSettings.tsx index 5055105..c976263 100644 --- a/src/renderer/src/components/settings/ProjectSettings.tsx +++ b/src/renderer/src/components/settings/ProjectSettings.tsx @@ -236,7 +236,7 @@ function scopeBooleanDefault(scope: Record, keys: string[], def } function slackListenDmsFromScope(scope: Record): boolean { - return scopeBooleanDefault(scope, ['listenDms', 'listenDirectMessages', 'directMessages'], true) + return scopeBooleanDefault(scope, ['listenDms', 'listenDirectMessages', 'directMessages'], false) } function localPathSegments(path: string): string[] {