Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/main/__tests__/integration-event-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getIntegrationEventTelemetrySnapshot,
IntegrationEventBridge,
createWorkspaceScopedEventClient,
eventPathGlobsForIntegration,
integrationSubscriptionSummaries,
integrationRelayFileSyncOptions,
localWatchEventPathsForFilename,
Expand Down Expand Up @@ -306,6 +307,29 @@ test('relayfile sdk path filters broaden partial-segment Slack DM globs', () =>
])
})

test('slack DM watch globs use the user-message model and drop vestigial /slack/dms', () => {
const slackDm = integration({
provider: 'slack',
integrationId: 'slack-1',
mountPaths: ['/slack/channels/C123ABC__proj-cloud'],
scope: { listenDms: true }
})
const globs = eventPathGlobsForIntegration(slackDm)
assert.ok(globs.includes('/slack/users/*/messages/**'), 'canonical user-message DM watch present')
assert.ok(globs.includes('/slack/channels/D*/**'), 'raw-D diagnostic alias retained')
assert.ok(!globs.includes('/slack/dms/*/**'), 'vestigial /slack/dms watch glob dropped')

const noDm = integration({
provider: 'slack',
integrationId: 'slack-2',
mountPaths: ['/slack/channels/C123ABC__proj-cloud'],
scope: { listenDms: false }
})
const noDmGlobs = eventPathGlobsForIntegration(noDm)
assert.ok(!noDmGlobs.includes('/slack/users/*/messages/**'), 'no DM watch when listenDms is off')
assert.ok(!noDmGlobs.includes('/slack/channels/D*/**'), 'no raw-D diagnostic watch when listenDms is off')
})

test('integration event remote stream keeps a refreshable relayfile token provider', () => {
const tokenProvider = async () => 'workspace-token'
const options = integrationRelayFileSyncOptions({
Expand Down
10 changes: 8 additions & 2 deletions src/main/integration-event-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,15 @@ const INTEGRATION_EVENT_LOG_PATH = join(homedir(), '.agentworkforce', 'pear', 'i
const AGGREGATED_WARNING_REPEAT_EVERY = 25
const MAX_AGGREGATED_WARNING_KEYS = 256
const SLACK_LIVE_EVENT_WINDOW_MS = 30 * 60 * 1_000
// DM event watch globs. Canonical 1:1 DM surface is the user-recipient model
// `/slack/users/<U>/messages` (where the adapter materializes message.im once
// D→U mapping lands). `/slack/channels/D*` is retained only as a diagnostic
// alias for raw Slack IM conversation ids the adapter still materializes
// channel-style today. `/slack/dms/*` was vestigial residue — no adapter
// resource or record ever materialized there — so it is intentionally dropped;
// it must not imply mounted/readable DM content.
const SLACK_DM_EVENT_GLOBS = [
'/slack/channels/D*/**',
'/slack/dms/*/**',
'/slack/users/*/messages/**'
]
const MAX_EVENT_CONTEXT_PREVIEW_BYTES = 32 * 1024
Expand Down Expand Up @@ -480,7 +486,7 @@ function watchGlobForPath(path: string): string {
return root.endsWith('/**') ? root : `${root || '/'}/**`
}

function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] {
export function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] {
return dedupeStrings([
...canonicalMountPaths(integration).map(watchGlobForPath),
...(slackListenDms(integration) ? SLACK_DM_EVENT_GLOBS : [])
Expand Down
66 changes: 61 additions & 5 deletions src/renderer/src/components/settings/ProjectSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ import { ProactiveAgentsSection } from '@/components/proactive/ProactiveAgentsSe
import { pear, type ConnectedIntegration } from '@/lib/ipc'
import {
SlackChannelPicker,
SlackDmRecipientPicker,
type IntegrationAccessibleResource,
type ScopePickerValue
} from '@/components/settings/scope-pickers'
import {
isSlackUserMessagesMountPath,
mergeSlackScopeMountPaths,
slackUserResourceFromOption
} from '@/components/settings/slack-scope'
import { useAgentStore } from '@/stores/agent-store'
import {
normalizeChannelName,
Expand Down Expand Up @@ -385,6 +391,7 @@ function IntegrationVisibilitySection({
const [error, setError] = useState<string | null>(null)
const [scopeEditorIntegrationId, setScopeEditorIntegrationId] = useState<string | null>(null)
const [pendingScopeValue, setPendingScopeValue] = useState<ScopePickerValue | null>(null)
const [pendingDmScopeValue, setPendingDmScopeValue] = useState<ScopePickerValue | null>(null)
const [pendingSlackListenDms, setPendingSlackListenDms] = useState<boolean | null>(null)
const resourceCacheRef = useRef(new Map<string, ResourceCacheEntry>())

Expand Down Expand Up @@ -634,6 +641,23 @@ function IntegrationVisibilitySection({
})
}, [cachedResources, projectId])

const listSlackDmRecipients = useCallback(async (integration: ConnectedIntegration): Promise<IntegrationAccessibleResource[]> => {
const cacheKey = `slack-users:${projectId}:${integration.integrationId}`
return cachedResources(cacheKey, async () => {
const listOptions = (pear.integrations as typeof pear.integrations & {
listOptions?: typeof pear.integrations.listOptions
}).listOptions
if (typeof listOptions !== 'function') {
throw new Error('Slack DM recipient options are not available yet.')
}
const options = await listOptions(projectId, integration.provider, 'users')
if (!Array.isArray(options)) {
throw new Error('Slack DM recipient options returned an unexpected response.')
}
return options.map(slackUserResourceFromOption)
})
}, [cachedResources, projectId])
Comment on lines +644 to +659
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If listOptions returns a non-array value (e.g., due to an unexpected API response or error state), calling options.map will throw a TypeError. Adding an Array.isArray(options) check ensures the application handles unexpected API payloads gracefully.

Suggested change
const listSlackDmRecipients = useCallback(async (integration: ConnectedIntegration): Promise<IntegrationAccessibleResource[]> => {
const cacheKey = `slack-users:${projectId}:${integration.integrationId}`
return cachedResources(cacheKey, async () => {
const listOptions = (pear.integrations as typeof pear.integrations & {
listOptions?: typeof pear.integrations.listOptions
}).listOptions
if (typeof listOptions !== 'function') {
throw new Error('Slack DM recipient options are not available yet.')
}
const options = await listOptions(projectId, integration.provider, 'users')
return options.map(slackUserResourceFromOption)
})
}, [cachedResources, projectId])
const listSlackDmRecipients = useCallback(async (integration: ConnectedIntegration): Promise<IntegrationAccessibleResource[]> => {
const cacheKey = 'slack-users:' + projectId + ':' + integration.integrationId
return cachedResources(cacheKey, async () => {
const listOptions = (pear.integrations as typeof pear.integrations & {
listOptions?: typeof pear.integrations.listOptions
}).listOptions
if (typeof listOptions !== 'function') {
throw new Error('Slack DM recipient options are not available yet.')
}
const options = await listOptions(projectId, integration.provider, 'users')
return Array.isArray(options) ? options.map(slackUserResourceFromOption) : []
})
}, [cachedResources, projectId])


const saveSlackSourceChannels = useCallback(async (integration: ConnectedIntegration) => {
const listenDms = pendingSlackListenDms ?? slackListenDmsFromScope(integration.scope)
const nextScope = {
Expand All @@ -642,8 +666,18 @@ function IntegrationVisibilitySection({
selection: pendingScopeValue?.scope.selection ?? integration.scope.selection ?? 'selected',
channels: pendingScopeValue?.scope.channels ?? integration.scope.channels ?? [],
resources: pendingScopeValue?.scope.resources ?? integration.scope.resources ?? [],
dmUsers: pendingDmScopeValue?.scope.dmUsers ?? integration.scope.dmUsers ?? [],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: DM recipient state can be unintentionally cleared when recipient options are empty/unavailable, because pending picker output overwrites existing dmUsers/DM mount paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/renderer/src/components/settings/ProjectSettings.tsx, line 666:

<comment>DM recipient state can be unintentionally cleared when recipient options are empty/unavailable, because pending picker output overwrites existing `dmUsers`/DM mount paths.</comment>

<file context>
@@ -642,8 +663,18 @@ function IntegrationVisibilitySection({
       selection: pendingScopeValue?.scope.selection ?? integration.scope.selection ?? 'selected',
       channels: pendingScopeValue?.scope.channels ?? integration.scope.channels ?? [],
       resources: pendingScopeValue?.scope.resources ?? integration.scope.resources ?? [],
+      dmUsers: pendingDmScopeValue?.scope.dmUsers ?? integration.scope.dmUsers ?? [],
       listenDms
     }
</file context>

listenDms
}
const nextMountPaths = mergeSlackScopeMountPaths({
existing: integration.mountPaths ?? [],
channelPaths: pendingScopeValue?.mountPaths ?? null,
// Only mount concrete `/slack/users/<U>/messages` recipients — never a bare
// `/slack/users` root (emitted when no users are available to select).
dmPaths: pendingDmScopeValue
? (pendingDmScopeValue.mountPaths ?? []).filter(isSlackUserMessagesMountPath)
: null
Comment on lines +677 to +679
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Guard against cases where pendingDmScopeValue is defined but mountPaths is missing or nullish at runtime, preventing potential TypeError crashes when calling .filter.

Suggested change
dmPaths: pendingDmScopeValue
? pendingDmScopeValue.mountPaths.filter(isSlackUserMessagesMountPath)
: null
dmPaths: pendingDmScopeValue?.mountPaths
? pendingDmScopeValue.mountPaths.filter(isSlackUserMessagesMountPath)
: null

})

setBusyIntegrationId(integration.integrationId)
setError(null)
Expand All @@ -652,7 +686,7 @@ function IntegrationVisibilitySection({
projectId,
integration.integrationId,
nextScope,
pendingScopeValue?.mountPaths ?? integration.mountPaths
nextMountPaths
)
setIntegrations((current) =>
current.map((entry) =>
Expand All @@ -661,13 +695,14 @@ function IntegrationVisibilitySection({
)
setScopeEditorIntegrationId(null)
setPendingScopeValue(null)
setPendingDmScopeValue(null)
setPendingSlackListenDms(null)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusyIntegrationId(null)
}
}, [pendingScopeValue, pendingSlackListenDms, projectId])
}, [pendingScopeValue, pendingDmScopeValue, pendingSlackListenDms, projectId])

return (
<Section
Expand Down Expand Up @@ -729,11 +764,19 @@ function IntegrationVisibilitySection({
const scopeEditorOpen = scopeEditorIntegrationId === integration.integrationId
const savedSlackListenDms = slackListenDmsFromScope(integration.scope)
const slackListenDms = pendingSlackListenDms ?? savedSlackListenDms
const integrationMountPaths = integration.mountPaths ?? []
const selectedSlackSourceIds = Array.from(new Set([
...integration.mountPaths,
...integrationMountPaths,
...scopeStringList(integration.scope, 'channels')
]))
Comment on lines +767 to 771
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter DM mount paths out of the channel picker seed state.

Lines 767-771 now feed every integration.mountPaths entry into selectedSlackSourceIds. After this PR, that includes DM mounts like /slack/users/<U>/messages, so a DM-only Slack integration no longer looks like “no prior channel selection” to GenericScopePicker. That changes the initial state of SlackChannelPicker and can turn a DM-only config into an apparent empty channel selection on the next save.

Suggested fix
             const slackListenDms = pendingSlackListenDms ?? savedSlackListenDms
             const integrationMountPaths = integration.mountPaths ?? []
+            const selectedSlackChannelMountPaths = integrationMountPaths.filter((path) =>
+              path.startsWith('/slack/channels/')
+            )
             const selectedSlackSourceIds = Array.from(new Set([
-              ...integrationMountPaths,
+              ...selectedSlackChannelMountPaths,
               ...scopeStringList(integration.scope, 'channels')
             ]))
📝 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.

Suggested change
const integrationMountPaths = integration.mountPaths ?? []
const selectedSlackSourceIds = Array.from(new Set([
...integration.mountPaths,
...integrationMountPaths,
...scopeStringList(integration.scope, 'channels')
]))
const integrationMountPaths = integration.mountPaths ?? []
const selectedSlackChannelMountPaths = integrationMountPaths.filter((path) =>
path.startsWith('/slack/channels/')
)
const selectedSlackSourceIds = Array.from(new Set([
...selectedSlackChannelMountPaths,
...scopeStringList(integration.scope, 'channels')
]))
🤖 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/renderer/src/components/settings/ProjectSettings.tsx` around lines 767 -
771, The code adds all integration.mountPaths into selectedSlackSourceIds, which
incorrectly includes DM mounts like "/slack/users/<U>/messages"; update the
logic that builds integrationMountPaths (and thus selectedSlackSourceIds) to
filter out DM-style mount paths before creating the Set. Specifically, transform
integration.mountPaths (used in the integrationMountPaths variable) by excluding
entries that match Slack DM patterns (e.g. paths starting with "/slack/users/")
so that only channel/conversation mount paths are merged with
scopeStringList(integration.scope, 'channels') for
GenericScopePicker/SlackChannelPicker initial state.

const slackScopeDirty = !!pendingScopeValue || (
const selectedSlackDmRecipientIds = Array.from(new Set([
...integrationMountPaths
.filter(isSlackUserMessagesMountPath)
.map((path) => path.split('/')[3])
.filter(Boolean),
...scopeStringList(integration.scope, 'dmUsers')
]))
Comment on lines +772 to +778
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If integration.mountPaths is nullish or undefined at runtime, calling .filter on it will throw a TypeError. Adding a fallback empty array (integration.mountPaths || []) ensures the component renders safely.

Suggested change
const selectedSlackDmRecipientIds = Array.from(new Set([
...integration.mountPaths
.filter(isSlackUserMessagesMountPath)
.map((path) => path.split('/')[3])
.filter(Boolean),
...scopeStringList(integration.scope, 'dmUsers')
]))
const selectedSlackDmRecipientIds = Array.from(new Set([
...(integration.mountPaths || [])
.filter(isSlackUserMessagesMountPath)
.map((path) => path.split('/')[3])
.filter(Boolean),
...scopeStringList(integration.scope, 'dmUsers')
]))

const slackScopeDirty = !!pendingScopeValue || !!pendingDmScopeValue || (
pendingSlackListenDms !== null && pendingSlackListenDms !== savedSlackListenDms
)
const knownTargetValues = new Set([
Expand Down Expand Up @@ -773,6 +816,7 @@ function IntegrationVisibilitySection({
disabled={busy}
onClick={() => {
setPendingScopeValue(null)
setPendingDmScopeValue(null)
setPendingSlackListenDms(scopeEditorOpen ? null : savedSlackListenDms)
setScopeEditorIntegrationId(scopeEditorOpen ? null : integration.integrationId)
}}
Expand Down Expand Up @@ -879,16 +923,28 @@ function IntegrationVisibilitySection({
/>
<span className="min-w-0">
<span className="block text-sm text-[var(--pear-text)]">Direct messages</span>
<span className="block text-xs text-[var(--pear-text-faint)]">Listen for Slack DMs</span>
<span className="block text-xs text-[var(--pear-text-faint)]">
Observe DM events (watch-only). Choose recipients below to read &amp; send their DMs.
</span>
</span>
</label>
<div className="mt-3">
<SlackDmRecipientPicker
provider="slack"
disabled={busy}
initialSelectedIds={selectedSlackDmRecipientIds}
listAccessibleResources={() => listSlackDmRecipients(integration)}
onChange={setPendingDmScopeValue}
/>
</div>
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
disabled={busy}
onClick={() => {
setScopeEditorIntegrationId(null)
setPendingScopeValue(null)
setPendingDmScopeValue(null)
setPendingSlackListenDms(null)
}}
className="h-8 rounded-md border border-[var(--pear-border)] px-3 text-xs text-[var(--pear-text-dim)] hover:text-[var(--pear-text)] disabled:opacity-40"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ type GenericScopePickerProps = ScopePickerProps & {
resourceNoun?: string
baseMountPath?: string
scopeKey?: string
// When no prior selection exists, select every resource by default (true) or
// none (false). Bounded "watch everything" surfaces like channels default to
// true; DM recipients default to false so we never mount every user's DMs.
defaultSelectAll?: boolean
getResourceLabel?: (resource: IntegrationAccessibleResource) => string
getResourceDescription?: (resource: IntegrationAccessibleResource) => string
getResourceMountSegment?: (resource: IntegrationAccessibleResource) => string
Expand Down Expand Up @@ -89,6 +93,7 @@ export function GenericScopePicker({
resourceNoun = 'resources',
baseMountPath = `/integrations/${provider}`,
scopeKey = 'resources',
defaultSelectAll = true,
getResourceLabel = (resource) => resourceText(resource, 'displayName', 'title', 'name', 'slug', 'key', 'path', 'id'),
getResourceDescription = (resource) => resourceText(resource, 'path', 'type', 'kind'),
getResourceMountSegment = (resource) => resourceText(resource, 'path', 'slug', 'key', 'name', 'id'),
Expand All @@ -103,13 +108,15 @@ export function GenericScopePicker({
const getResourceScopeIdRef = useRef(getResourceScopeId)
const listAccessibleResourcesRef = useRef(listAccessibleResources)
const initialSelectedIdsRef = useRef(initialSelectedIds)
const defaultSelectAllRef = useRef(defaultSelectAll)

useEffect(() => {
onChangeRef.current = onChange
getResourceMountSegmentRef.current = getResourceMountSegment
getResourceScopeIdRef.current = getResourceScopeId
listAccessibleResourcesRef.current = listAccessibleResources
initialSelectedIdsRef.current = initialSelectedIds
defaultSelectAllRef.current = defaultSelectAll
})

useEffect(() => {
Expand All @@ -126,7 +133,9 @@ export function GenericScopePicker({
new Set(
seeds && seeds.length > 0
? seeds
: nextResources.map(resourceId)
: defaultSelectAllRef.current
? nextResources.map(resourceId)
: []
)
)
})
Expand All @@ -153,9 +162,10 @@ export function GenericScopePicker({
)

useEffect(() => {
if (loading) return
if (loading || error) return

if (resources.length === 0) {
if (!defaultSelectAll) return
onChangeRef.current({
scope: { provider, selection: 'all', [scopeKey]: [] },
mountPaths: [baseMountPath]
Expand All @@ -181,6 +191,8 @@ export function GenericScopePicker({
})
}, [
baseMountPath,
defaultSelectAll,
error,
loading,
provider,
resources.length,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type React from 'react'
import { GenericScopePicker, metadataText, resourceText, type ScopePickerProps } from './GenericScopePicker'
import { slackDmMountSegment, slackDmUserId } from '../slack-scope'

export function SlackDmRecipientPicker(props: ScopePickerProps): React.ReactNode {
return (
<GenericScopePicker
{...props}
title="Choose DM recipients"
resourceNoun="people"
baseMountPath="/slack/users"
scopeKey="dmUsers"
defaultSelectAll={false}
getResourceLabel={(resource) => {
const name = resourceText(resource, 'displayName', 'name', 'title')
if (!name) return slackDmUserId(resource)
return name.startsWith('@') ? name : `@${name}`
}}
getResourceDescription={(resource) =>
metadataText(resource, 'realName', 'email', 'workspace', 'team') || slackDmUserId(resource)
}
getResourceMountSegment={slackDmMountSegment}
getResourceScopeId={slackDmUserId}
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { GitHubRepoPicker } from './GitHubRepoPicker'
export { LinearTeamPicker } from './LinearTeamPicker'
export { NotionDatabasePicker } from './NotionDatabasePicker'
export { SlackChannelPicker } from './SlackChannelPicker'
export { SlackDmRecipientPicker } from './SlackDmRecipientPicker'
Loading
Loading