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
114 changes: 92 additions & 22 deletions src/main/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ const mock = vi.hoisted(() => {
provider: 'slack',
integrationId: 'slack-integration-1',
scope: {},
mountPaths: ['/slack/channels'],
// Narrow channel mount → a writeback command root is mounted, so the default
// fixture is "active" (the integrations-update snippet now omits idle ones).
mountPaths: ['/slack/channels/C123'],
connectedAt: '2026-06-05T00:00:00.000Z',
notifyAgent: true,
subscribeAgent: false,
Expand Down Expand Up @@ -315,11 +317,13 @@ vi.mock('./relay-workspace', () => ({
}))

import { IntegrationsManager, localSyncMountPathsForIntegration } from './integrations'
// Mocked module (see vi.mock('./integration-event-bridge') above): importing the
// stubbed function lets a test override what subscription summaries it returns.
import { integrationSubscriptionSummaries } from './integration-event-bridge'

type SystemMessageSnippetBuilder = {
buildSystemMessageSnippet(
integrations: Array<Parameters<typeof localSyncMountPathsForIntegration>[0]>,
subscriptionsReady: boolean
integrations: Array<Parameters<typeof localSyncMountPathsForIntegration>[0]>
): string
}

Expand Down Expand Up @@ -982,7 +986,7 @@ describe('IntegrationsManager', () => {
notifyAgent: true,
subscribeAgent: true,
downloadHistoricalData: false
}], true)
}])

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')
Expand All @@ -1002,7 +1006,7 @@ describe('IntegrationsManager', () => {
notifyAgent: true,
subscribeAgent: true,
downloadHistoricalData: false
}], true)
}])

expect(message).toContain('create writeback files under .integrations/linear/issues')
expect(message).toContain('Writeback command roots are mounted at .integrations/linear/issues')
Expand All @@ -1020,12 +1024,47 @@ describe('IntegrationsManager', () => {
notifyAgent: true,
subscribeAgent: true,
downloadHistoricalData: true
}], true)
}])

expect(message).toContain('not locally poll-mounted')
expect(message).toContain('.integrations/slack/channels/C023')
})

it('treats a subscribed-but-otherwise-idle integration as active (listening)', () => {
const manager = new IntegrationsManager() as unknown as SystemMessageSnippetBuilder
// No history sync and a base mount path (no narrow writeback command roots),
// so the integration is active only by virtue of "listening" — a registered
// event subscription.
const idleSubscribedLinear = {
provider: 'linear',
integrationId: 'linear-integration-1',
scope: {},
mountPaths: ['/linear'],
connectedAt: '2026-06-05T00:00:00.000Z',
notifyAgent: true,
subscribeAgent: true,
downloadHistoricalData: false
}

// Without an active subscription summary the gate suppresses the message:
// not syncing, not actionable, not listening.
vi.mocked(integrationSubscriptionSummaries).mockReturnValueOnce([])
expect(manager.buildSystemMessageSnippet([idleSubscribedLinear])).toBe('')

// With a subscription summary present the integration is listening, so the
// message renders and lists the active subscription.
vi.mocked(integrationSubscriptionSummaries).mockReturnValue([
{ provider: 'linear', watches: ['.integrations/linear/**'], targets: [] }
])
const message = manager.buildSystemMessageSnippet([idleSubscribedLinear])
expect(message).toContain('<integrations-update>')
expect(message).toContain('linear')
expect(message).toContain('Active integration event subscriptions for this project')
expect(message).toContain('file changes at .integrations/linear/**')

vi.mocked(integrationSubscriptionSummaries).mockReturnValue([])
})

it('does not block updateScope on integration state sync', async () => {
let finishMountReconcile!: () => void
mock.setMountReconcilePromise(new Promise((resolve) => {
Expand Down Expand Up @@ -1171,8 +1210,9 @@ describe('IntegrationsManager', () => {
// Hermetic snippet text: background cloud hydration re-adds integrations
// to the store and changes the rendered text between broadcasts (the
// separate staged-churn case the cooldown test covers). Block hydration
// and pin the store empty so the text is identical across reconciles.
mock.store.projects[0].integrations = []
// and pin a single active integration so the text is identical across
// reconciles (an idle/empty project now produces no snippet at all).
mock.store.projects[0].integrations = [makeIntegration('slack', ['/slack/channels/C123'])]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
mock.getAccountWorkspaceId.mockRejectedValue(new Error('offline'))
mock.brokerManager.listAgents.mockResolvedValue([
{ name: 'claude-1', projectId: 'project-1' },
Expand Down Expand Up @@ -1204,7 +1244,7 @@ describe('IntegrationsManager', () => {

it('sends an unchanged update to a newly arrived agent without re-sending to existing agents', async () => {
vi.useFakeTimers()
mock.store.projects[0].integrations = []
mock.store.projects[0].integrations = [makeIntegration('slack', ['/slack/channels/C123'])]
mock.getAccountWorkspaceId.mockRejectedValue(new Error('offline'))
mock.brokerManager.listAgents.mockResolvedValue([{ name: 'claude-1', projectId: 'project-1' }] as never)
const manager = new IntegrationsManager()
Expand Down Expand Up @@ -1232,10 +1272,10 @@ describe('IntegrationsManager', () => {
provider: 'linear',
integrationId: 'linear-integration-1',
scope: {},
mountPaths: ['/linear'],
mountPaths: ['/linear/issues'],
connectedAt: '2026-06-05T00:00:00.000Z',
notifyAgent: true,
subscribeAgent: false,
subscribeAgent: true,
downloadHistoricalData: false,
visibleInProject: true
}]
Expand Down Expand Up @@ -1271,10 +1311,10 @@ describe('IntegrationsManager', () => {
provider: 'linear',
integrationId: 'linear-integration-1',
scope: {},
mountPaths: ['/linear'],
mountPaths: ['/linear/issues'],
connectedAt: '2026-06-05T00:00:00.000Z',
notifyAgent: true,
subscribeAgent: false,
subscribeAgent: true,
downloadHistoricalData: false,
visibleInProject: true
}]
Expand All @@ -1286,10 +1326,11 @@ describe('IntegrationsManager', () => {
manager.recordSpawnInstructionDelivery('project-1', 'codex-1')

// Simulate staged churn: the store changes right after the spawn, so the
// broadcast text differs from the embedded snippet.
// broadcast text differs from the embedded snippet (historical download
// flips on, which rewrites the history clause).
mock.store.projects[0].integrations = [{
...mock.store.projects[0].integrations[0],
mountPaths: ['/linear/issues']
downloadHistoricalData: true
}]
await manager.notifyAgentState('project-1')
await vi.advanceTimersByTimeAsync(5_000)
Expand All @@ -1309,7 +1350,7 @@ describe('IntegrationsManager', () => {

it('holds a changed update inside the rebroadcast cooldown and sends the latest text once', async () => {
vi.useFakeTimers()
mock.store.projects[0].integrations = []
mock.store.projects[0].integrations = [makeIntegration('slack', ['/slack/channels/C123'])]
mock.getAccountWorkspaceId.mockRejectedValue(new Error('offline'))
mock.brokerManager.listAgents.mockResolvedValue([{ name: 'claude-1', projectId: 'project-1' }] as never)
const manager = new IntegrationsManager()
Expand All @@ -1330,10 +1371,10 @@ describe('IntegrationsManager', () => {
provider: 'linear',
integrationId: 'linear-integration-1',
scope: {},
mountPaths: ['/linear'],
mountPaths: ['/linear/issues'],
connectedAt: '2026-06-05T00:00:00.000Z',
notifyAgent: true,
subscribeAgent: false,
subscribeAgent: true,
downloadHistoricalData: false,
visibleInProject: true
}]
Expand All @@ -1346,12 +1387,41 @@ describe('IntegrationsManager', () => {
expect(updateTexts()[1]).toContain('linear')
})

it('does not re-send an unchanged integrations update after the dedupe window lapses', async () => {
it('broadcasts one clearing update when the last active integration is removed', async () => {
vi.useFakeTimers()
// Pin the state so the rendered text is identical across reconciles
// (background hydration would otherwise change it, which is the
// legitimate-rebroadcast case).
mock.store.projects[0].integrations = [makeIntegration('slack', ['/slack/channels/C123'])]
mock.getAccountWorkspaceId.mockRejectedValue(new Error('offline'))
mock.brokerManager.listAgents.mockResolvedValue([{ name: 'claude-1', projectId: 'project-1' }] as never)
const manager = new IntegrationsManager()
const updateTexts = (): string[] => integrationUpdateSendInputs().map((input) => input.text)

await manager.notifyAgentState('project-1')
await vi.advanceTimersByTimeAsync(1_000)
expect(updateTexts()).toHaveLength(1)
expect(updateTexts()[0]).toContain('.integrations/slack/channels/C123')

// Last integration disconnected: agents already told about it must get one
// clearing update rather than silence (otherwise they keep stale context).
mock.store.projects[0].integrations = []
await vi.advanceTimersByTimeAsync(60_000) // clear the rebroadcast cooldown
await manager.notifyAgentState('project-1')
await vi.advanceTimersByTimeAsync(1_000)
expect(updateTexts()).toHaveLength(2)
expect(updateTexts()[1]).toContain('- none')

// A further idle reconcile stays silent — the prior context was cleared once.
await vi.advanceTimersByTimeAsync(60_000)
await manager.notifyAgentState('project-1')
await vi.advanceTimersByTimeAsync(60_000)
expect(updateTexts()).toHaveLength(2)
})

it('does not re-send an unchanged integrations update after the dedupe window lapses', async () => {
vi.useFakeTimers()
// Pin a single active integration so the rendered text is identical across
// reconciles (background hydration would otherwise change it, which is the
// legitimate-rebroadcast case; an idle/empty project now yields no snippet).
mock.store.projects[0].integrations = [makeIntegration('slack', ['/slack/channels/C123'])]
mock.getAccountWorkspaceId.mockRejectedValue(new Error('offline'))
mock.brokerManager.listAgents.mockResolvedValue([{ name: 'claude-1', projectId: 'project-1' }] as never)
const manager = new IntegrationsManager()
Expand Down
Loading
Loading