11import assert from 'node:assert/strict'
2- import { mkdir , mkdtemp , rm , writeFile } from 'node:fs/promises'
2+ import { mkdir , mkdtemp , rm , stat , writeFile } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55import { beforeEach , test } from 'node:test'
@@ -15,7 +15,8 @@ import {
1515 localWatchEventPathsForFilename ,
1616 localWatchRootsFor ,
1717 relayfileSdkPathFiltersFor ,
18- resetIntegrationEventTelemetryForTests
18+ resetIntegrationEventTelemetryForTests ,
19+ subscriptionSpecsFor
1920} from '../integration-event-bridge.ts'
2021import type { ConnectedIntegration } from '../integrations.ts'
2122
@@ -150,6 +151,16 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 2_000): Promise<v
150151 assert . equal ( predicate ( ) , true )
151152}
152153
154+ async function waitForPathMissing ( path : string , timeoutMs = 2_000 ) : Promise < void > {
155+ const deadline = Date . now ( ) + timeoutMs
156+ while ( Date . now ( ) <= deadline ) {
157+ const stats = await stat ( path ) . catch ( ( ) => null )
158+ if ( ! stats ) return
159+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) )
160+ }
161+ assert . equal ( await stat ( path ) . then ( ( ) => true ) . catch ( ( ) => false ) , false )
162+ }
163+
153164function makeHarness (
154165 agents = [ 'alice' , 'bob' ] ,
155166 options : {
@@ -1416,6 +1427,64 @@ test('slack context falls back to expanded event data when targeted remote previ
14161427 assert . equal ( ( harness . sent [ 0 ] . input . data ?. contextPreview as { content ?: string } | undefined ) ?. content , undefined )
14171428} )
14181429
1430+ test ( 'slack DM context uses materialized local file when targeted remote preview is missing' , async ( ) => {
1431+ const workspaceRoot = await mkdtemp ( join ( tmpdir ( ) , 'pear-event-preview-' ) )
1432+ const localRoot = join ( workspaceRoot , 'workspace-id' , 'slack' , 'users' , 'U0ADJH4P83T' , 'messages' )
1433+ const messagePath = '/slack/users/U0ADJH4P83T/messages/1780905125_300069/meta.json'
1434+ await mkdir ( join ( localRoot , '1780905125_300069' ) , { recursive : true } )
1435+ await writeFile (
1436+ join ( localRoot , '1780905125_300069' , 'meta.json' ) ,
1437+ JSON . stringify ( {
1438+ provider : 'slack' ,
1439+ text : 'local Slack DM context' ,
1440+ user : 'U123' ,
1441+ dm_user_id : 'U0ADJH4P83T'
1442+ } )
1443+ )
1444+
1445+ try {
1446+ const harness = makeHarness ( [ 'alice' ] , { failReadFile : true } )
1447+
1448+ await withMockedNow ( '2026-06-05T14:00:00.000Z' , async ( ) => {
1449+ await harness . bridge . reconcile ( 'project-1' , [
1450+ integration ( {
1451+ provider : 'slack' ,
1452+ integrationId : 'slack-1' ,
1453+ mountPaths : [ '/slack/users/U0ADJH4P83T/messages' ] ,
1454+ localMountPaths : [ localRoot ] ,
1455+ downloadHistoricalData : false ,
1456+ scope : { listenDms : true , notifyAgents : [ 'alice' ] }
1457+ } )
1458+ ] )
1459+ } )
1460+
1461+ await harness . emit ( {
1462+ ...changeEvent ( messagePath , 'slack' ) ,
1463+ expand : async ( ) => ( {
1464+ level : 'full' ,
1465+ path : messagePath ,
1466+ data : {
1467+ path : messagePath ,
1468+ deleted : false
1469+ }
1470+ } )
1471+ } as ChangeEvent )
1472+ await waitForSent ( harness , 1 , 2_500 )
1473+
1474+ assert . match ( harness . sent [ 0 ] . input . text , / S l a c k m e s s a g e e v e n t / u)
1475+ assert . match ( harness . sent [ 0 ] . input . text , / L o c a t i o n : U s e r U 0 A D J H 4 P 8 3 T / u)
1476+ assert . match ( harness . sent [ 0 ] . input . text , / A u t h o r : U 1 2 3 / u)
1477+ assert . match ( harness . sent [ 0 ] . input . text , / M e s s a g e : \n l o c a l S l a c k D M c o n t e x t / u)
1478+ assert . doesNotMatch ( harness . sent [ 0 ] . input . text , / M e s s a g e : u n a v a i l a b l e / u)
1479+ assert . equal (
1480+ ( harness . sent [ 0 ] . input . data ?. contextPreview as { path ?: string } | undefined ) ?. path ,
1481+ messagePath
1482+ )
1483+ } finally {
1484+ await rm ( workspaceRoot , { recursive : true , force : true } )
1485+ }
1486+ } )
1487+
14191488test ( 'slack context retries targeted remote preview before falling back to sparse event data' , async ( ) => {
14201489 const harness = makeHarness ( [ 'alice' ] , { readFileFailuresBeforeSuccess : 1 } )
14211490 const messagePath = '/slack/channels/D123ABC/messages/1780668000_000000/meta.json'
@@ -1830,6 +1899,30 @@ test('local fallback watchers use the shared Slack users command-root grammar',
18301899 )
18311900} )
18321901
1902+ test ( 'subscription specs include concrete local roots for Slack user message mounts' , ( ) => {
1903+ const localRoot = join ( '/tmp' , 'relayfile' , 'workspaces' , 'workspace-id' , 'slack' , 'users' , 'U123' , 'messages' )
1904+ const specs = subscriptionSpecsFor (
1905+ [
1906+ integration ( {
1907+ provider : 'slack' ,
1908+ integrationId : 'slack-1' ,
1909+ mountPaths : [ '/slack/users/U123/messages' ] ,
1910+ localMountPaths : [ localRoot ] ,
1911+ scope : { listenDms : true }
1912+ } )
1913+ ] ,
1914+ 'workspace-id'
1915+ )
1916+
1917+ assert . equal ( specs . length , 1 )
1918+ assert . deepEqual ( specs [ 0 ] . localMountRoots , [
1919+ {
1920+ localRoot,
1921+ remoteRoot : '/slack/users/U123/messages'
1922+ }
1923+ ] )
1924+ } )
1925+
18331926test ( 'local watcher path construction does not duplicate remote path segments' , ( ) => {
18341927 const messageLocalRoot = join ( '/tmp' , 'relayfile' , 'workspaces' , 'workspace-id' , 'slack' , 'channels' , 'C0AD7UU0J1G' , 'messages' , '1780019742_971719' )
18351928 const messageRemoteRoot = '/slack/channels/C0AD7UU0J1G/messages/1780019742_971719'
@@ -2021,6 +2114,68 @@ test('integration events ignore index, discovery, tmp, dotfile, and local writeb
20212114 assert . deepEqual ( harness . listAgentsCalls , [ ] )
20222115} )
20232116
2117+ test ( 'confirmed Slack writeback success removes the local draft command file' , async ( ) => {
2118+ const workspaceRoot = await mkdtemp ( join ( tmpdir ( ) , 'pear-writeback-cleanup-' ) )
2119+ const localRoot = join ( workspaceRoot , 'workspace-id' , 'slack' , 'channels' , 'C123ABC' , 'messages' )
2120+ const localDraftPath = join ( localRoot , 'reply-confirmed.json' )
2121+ const remoteDraftPath = '/slack/channels/C123ABC/messages/reply-confirmed.json'
2122+ await mkdir ( localRoot , { recursive : true } )
2123+ await writeFile ( localDraftPath , JSON . stringify ( { text : 'confirmed send' } ) )
2124+
2125+ try {
2126+ const harness = makeHarness ( [ 'alice' ] )
2127+ await harness . bridge . reconcile ( 'project-1' , [
2128+ integration ( {
2129+ provider : 'slack' ,
2130+ integrationId : 'slack-1' ,
2131+ mountPaths : [ '/slack/channels/C123ABC/messages' ] ,
2132+ localMountPaths : [ localRoot ] ,
2133+ scope : { notifyAgents : [ 'alice' ] }
2134+ } )
2135+ ] )
2136+
2137+ await harness . emit ( {
2138+ ...changeEvent ( remoteDraftPath , 'slack' ) ,
2139+ type : 'writeback.succeeded'
2140+ } as ChangeEvent )
2141+ await waitForPathMissing ( localDraftPath )
2142+
2143+ assert . deepEqual ( harness . sent , [ ] )
2144+ } finally {
2145+ await rm ( workspaceRoot , { recursive : true , force : true } )
2146+ }
2147+ } )
2148+
2149+ test ( 'Slack writeback draft cleanup waits for confirmed dispatch' , async ( ) => {
2150+ const workspaceRoot = await mkdtemp ( join ( tmpdir ( ) , 'pear-writeback-cleanup-' ) )
2151+ const localRoot = join ( workspaceRoot , 'workspace-id' , 'slack' , 'channels' , 'C123ABC' , 'messages' )
2152+ const localDraftPath = join ( localRoot , 'reply-pending.json' )
2153+ const remoteDraftPath = '/slack/channels/C123ABC/messages/reply-pending.json'
2154+ await mkdir ( localRoot , { recursive : true } )
2155+ await writeFile ( localDraftPath , JSON . stringify ( { text : 'pending send' } ) )
2156+
2157+ try {
2158+ const harness = makeHarness ( [ 'alice' ] )
2159+ await harness . bridge . reconcile ( 'project-1' , [
2160+ integration ( {
2161+ provider : 'slack' ,
2162+ integrationId : 'slack-1' ,
2163+ mountPaths : [ '/slack/channels/C123ABC/messages' ] ,
2164+ localMountPaths : [ localRoot ] ,
2165+ scope : { notifyAgents : [ 'alice' ] }
2166+ } )
2167+ ] )
2168+
2169+ await harness . emit ( changeEvent ( remoteDraftPath , 'slack' ) )
2170+ await waitForDispatcherTick ( )
2171+
2172+ assert . equal ( await stat ( localDraftPath ) . then ( ( ) => true ) . catch ( ( ) => false ) , true )
2173+ assert . deepEqual ( harness . sent , [ ] )
2174+ } finally {
2175+ await rm ( workspaceRoot , { recursive : true , force : true } )
2176+ }
2177+ } )
2178+
20242179test ( 'integration events notify nested non-numeric Slack message records' , async ( ) => {
20252180 const harness = makeHarness ( )
20262181
0 commit comments