@agent-relay/dashboard 2.0.81 → 2.0.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/404.html +1 -1
- package/out/_next/static/chunks/{118-4c8241b0218335de.js → 118-ae2b650136a5a5fc.js} +1 -1
- package/out/_next/static/chunks/407-0c82986cf79c8ecb.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/{page-1e81c047cff17212.js → page-f7eca1b66fb4249b.js} +1 -1
- package/out/_next/static/chunks/app/{page-6892fe2dd07fb48b.js → page-0ee604f7070d14c0.js} +1 -1
- package/out/_next/static/css/8968d98ed4c4d33f.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +2 -2
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +1 -1
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +1 -1
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +1 -1
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +1 -1
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +1 -1
- package/out/providers.html +1 -1
- package/out/providers.txt +1 -1
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +1 -1
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +7 -1
- package/src/app/about/page.tsx +7 -0
- package/src/app/app/[[...slug]]/DashboardPageClient.tsx +853 -0
- package/src/app/app/[[...slug]]/page.tsx +23 -0
- package/src/app/app/onboarding/page.tsx +394 -0
- package/src/app/apple-icon.png +0 -0
- package/src/app/blog/go-to-bed-wake-up-to-a-finished-product/page.tsx +88 -0
- package/src/app/blog/let-them-cook-multi-agent-orchestration/page.tsx +93 -0
- package/src/app/blog/page.tsx +15 -0
- package/src/app/careers/page.tsx +7 -0
- package/src/app/changelog/page.tsx +7 -0
- package/src/app/cloud/link/page.tsx +464 -0
- package/src/app/complete-profile/page.tsx +204 -0
- package/src/app/connect-repos/page.tsx +410 -0
- package/src/app/contact/page.tsx +7 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/favicon.png +0 -0
- package/src/app/globals.css +200 -0
- package/src/app/history/page.tsx +658 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/login/page.tsx +424 -0
- package/src/app/metrics/page.tsx +781 -0
- package/src/app/page.tsx +59 -0
- package/src/app/pricing/page.tsx +7 -0
- package/src/app/privacy/page.tsx +7 -0
- package/src/app/providers/page.tsx +193 -0
- package/src/app/providers/setup/[provider]/ProviderSetupClient.tsx +197 -0
- package/src/app/providers/setup/[provider]/constants.ts +35 -0
- package/src/app/providers/setup/[provider]/page.tsx +42 -0
- package/src/app/security/page.tsx +7 -0
- package/src/app/signup/page.tsx +533 -0
- package/src/app/terms/page.tsx +7 -0
- package/src/components/ActivityFeed.tsx +216 -0
- package/src/components/AddWorkspaceModal.tsx +170 -0
- package/src/components/AgentCard.test.tsx +134 -0
- package/src/components/AgentCard.tsx +585 -0
- package/src/components/AgentList.test.tsx +147 -0
- package/src/components/AgentList.tsx +419 -0
- package/src/components/AgentLogPreview.tsx +173 -0
- package/src/components/AgentProfilePanel.tsx +569 -0
- package/src/components/App.tsx +3424 -0
- package/src/components/BillingPanel.tsx +922 -0
- package/src/components/BillingResult.tsx +447 -0
- package/src/components/BroadcastComposer.tsx +690 -0
- package/src/components/ChannelAdminPanel.tsx +773 -0
- package/src/components/ChannelBrowser.tsx +385 -0
- package/src/components/ChannelChat.tsx +261 -0
- package/src/components/ChannelSidebar.tsx +399 -0
- package/src/components/CloudSessionProvider.tsx +130 -0
- package/src/components/CommandPalette.tsx +815 -0
- package/src/components/ConfirmationDialog.tsx +133 -0
- package/src/components/ConversationHistory.tsx +518 -0
- package/src/components/CoordinatorPanel.tsx +956 -0
- package/src/components/DecisionQueue.tsx +717 -0
- package/src/components/DirectMessageView.tsx +164 -0
- package/src/components/FileAutocomplete.tsx +368 -0
- package/src/components/FleetOverview.tsx +278 -0
- package/src/components/LogViewer.tsx +310 -0
- package/src/components/LogViewerPanel.tsx +482 -0
- package/src/components/Logo.tsx +284 -0
- package/src/components/MentionAutocomplete.tsx +384 -0
- package/src/components/MessageComposer.tsx +473 -0
- package/src/components/MessageList.tsx +725 -0
- package/src/components/MessageSenderName.tsx +91 -0
- package/src/components/MessageStatusIndicator.tsx +142 -0
- package/src/components/NewConversationModal.tsx +400 -0
- package/src/components/NotificationToast.tsx +488 -0
- package/src/components/OnlineUsersIndicator.tsx +164 -0
- package/src/components/Pagination.tsx +124 -0
- package/src/components/PricingPlans.tsx +386 -0
- package/src/components/ProjectList.tsx +711 -0
- package/src/components/ProviderAuthFlow.tsx +343 -0
- package/src/components/ProviderConnectionList.tsx +375 -0
- package/src/components/ProvisioningProgress.tsx +730 -0
- package/src/components/ReactionChips.tsx +70 -0
- package/src/components/ReactionPicker.tsx +121 -0
- package/src/components/RepoAccessPanel.tsx +787 -0
- package/src/components/RepositoriesPanel.tsx +901 -0
- package/src/components/ServerCard.tsx +202 -0
- package/src/components/SessionExpiredModal.tsx +128 -0
- package/src/components/SpawnModal.test.tsx +190 -0
- package/src/components/SpawnModal.tsx +1001 -0
- package/src/components/TaskAssignmentUI.tsx +375 -0
- package/src/components/TerminalProviderSetup.tsx +517 -0
- package/src/components/ThemeProvider.tsx +159 -0
- package/src/components/ThinkingIndicator.tsx +231 -0
- package/src/components/ThreadList.tsx +198 -0
- package/src/components/ThreadPanel.tsx +405 -0
- package/src/components/TrajectoryViewer.tsx +698 -0
- package/src/components/TypingIndicator.tsx +69 -0
- package/src/components/UsageBanner.tsx +231 -0
- package/src/components/UserProfilePanel.tsx +233 -0
- package/src/components/WorkspaceContext.tsx +95 -0
- package/src/components/WorkspaceSelector.tsx +234 -0
- package/src/components/WorkspaceStatusIndicator.tsx +396 -0
- package/src/components/XTermInteractive.tsx +516 -0
- package/src/components/XTermLogViewer.tsx +719 -0
- package/src/components/channels/ChannelDialogs.tsx +1411 -0
- package/src/components/channels/ChannelHeader.tsx +317 -0
- package/src/components/channels/ChannelMessageList.tsx +463 -0
- package/src/components/channels/ChannelViewV1.tsx +146 -0
- package/src/components/channels/MessageInput.tsx +302 -0
- package/src/components/channels/SearchInput.tsx +172 -0
- package/src/components/channels/SearchResults.tsx +336 -0
- package/src/components/channels/api.test.ts +1527 -0
- package/src/components/channels/api.ts +703 -0
- package/src/components/channels/index.ts +76 -0
- package/src/components/channels/mockApi.ts +344 -0
- package/src/components/channels/types.ts +566 -0
- package/src/components/hooks/index.ts +58 -0
- package/src/components/hooks/useAgentLogs.ts +504 -0
- package/src/components/hooks/useAgents.ts +127 -0
- package/src/components/hooks/useBroadcastDedup.test.ts +371 -0
- package/src/components/hooks/useBroadcastDedup.ts +86 -0
- package/src/components/hooks/useChannelAdmin.ts +329 -0
- package/src/components/hooks/useChannelBrowser.ts +239 -0
- package/src/components/hooks/useChannelCommands.ts +138 -0
- package/src/components/hooks/useChannels.ts +367 -0
- package/src/components/hooks/useDebounce.ts +29 -0
- package/src/components/hooks/useDirectMessage.test.ts +952 -0
- package/src/components/hooks/useDirectMessage.ts +141 -0
- package/src/components/hooks/useMessages.ts +310 -0
- package/src/components/hooks/useOrchestrator.test.ts +165 -0
- package/src/components/hooks/useOrchestrator.ts +424 -0
- package/src/components/hooks/usePinnedAgents.test.ts +356 -0
- package/src/components/hooks/usePinnedAgents.ts +140 -0
- package/src/components/hooks/usePresence.test.ts +245 -0
- package/src/components/hooks/usePresence.ts +377 -0
- package/src/components/hooks/useRecentRepos.ts +130 -0
- package/src/components/hooks/useSession.ts +209 -0
- package/src/components/hooks/useThread.ts +138 -0
- package/src/components/hooks/useTrajectory.ts +265 -0
- package/src/components/hooks/useWebSocket.ts +290 -0
- package/src/components/hooks/useWorkspaceMembers.ts +132 -0
- package/src/components/hooks/useWorkspaceRepos.ts +73 -0
- package/src/components/hooks/useWorkspaceStatus.ts +237 -0
- package/src/components/index.ts +81 -0
- package/src/components/layout/Header.tsx +311 -0
- package/src/components/layout/RepoContextHeader.tsx +361 -0
- package/src/components/layout/Sidebar.archive.test.tsx +126 -0
- package/src/components/layout/Sidebar.test.tsx +691 -0
- package/src/components/layout/Sidebar.tsx +900 -0
- package/src/components/layout/index.ts +7 -0
- package/src/components/settings/BillingSettingsPanel.tsx +564 -0
- package/src/components/settings/SettingsPage.tsx +683 -0
- package/src/components/settings/TeamSettingsPanel.tsx +560 -0
- package/src/components/settings/WorkspaceSettingsPanel.tsx +1368 -0
- package/src/components/settings/index.ts +11 -0
- package/src/components/settings/types.ts +79 -0
- package/src/components/utils/messageFormatting.test.tsx +331 -0
- package/src/components/utils/messageFormatting.tsx +597 -0
- package/src/index.ts +63 -0
- package/src/landing/AboutPage.tsx +77 -0
- package/src/landing/BlogContent.tsx +187 -0
- package/src/landing/BlogPage.tsx +47 -0
- package/src/landing/CareersPage.tsx +53 -0
- package/src/landing/ChangelogPage.tsx +33 -0
- package/src/landing/ContactPage.tsx +41 -0
- package/src/landing/DocsPage.tsx +43 -0
- package/src/landing/LandingPage.tsx +702 -0
- package/src/landing/PricingPage.tsx +549 -0
- package/src/landing/PrivacyPage.tsx +117 -0
- package/src/landing/SecurityPage.tsx +42 -0
- package/src/landing/StaticPage.tsx +165 -0
- package/src/landing/TermsPage.tsx +125 -0
- package/src/landing/blogData.ts +312 -0
- package/src/landing/index.ts +18 -0
- package/src/landing/styles.css +3673 -0
- package/src/lib/agent-merge.test.ts +43 -0
- package/src/lib/agent-merge.ts +35 -0
- package/src/lib/api.ts +1294 -0
- package/src/lib/cloudApi.ts +893 -0
- package/src/lib/colors.test.ts +175 -0
- package/src/lib/colors.ts +218 -0
- package/src/lib/config.ts +109 -0
- package/src/lib/hierarchy.ts +242 -0
- package/src/lib/stuckDetection.ts +142 -0
- package/src/lib/useUrlRouting.ts +190 -0
- package/src/types/index.ts +317 -0
- package/src/types/threading.ts +7 -0
- package/out/_next/static/chunks/285-dc644487a8d6500d.js +0 -1
- package/out/_next/static/css/4c58d9cf493aa626.css +0 -1
- /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_buildManifest.js +0 -0
- /package/out/_next/static/{dYlczDQI12PIQ3tqq3N4Y → IxfA6RZu4trcsEMYlkQra}/_ssgManifest.js +0 -0
- /package/out/_next/static/chunks/{528-d375bc8b46912d2c.js → 528-f5f676996d613c25.js} +0 -0
- /package/out/_next/static/chunks/app/blog/let-them-cook-multi-agent-orchestration/{page-a58308f43557b908.js → page-b194f207fbd91862.js} +0 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for broadcast message deduplication in #general channel
|
|
3
|
+
*
|
|
4
|
+
* TDD approach: Write failing tests first, then fix the implementation.
|
|
5
|
+
*
|
|
6
|
+
* Problem: When a broadcast is sent (to='*'), the backend delivers it to each
|
|
7
|
+
* recipient separately. Each delivery gets a unique ID and is stored separately.
|
|
8
|
+
* In #general channel, this causes the same message to appear multiple times
|
|
9
|
+
* (once per recipient).
|
|
10
|
+
*
|
|
11
|
+
* Solution: Deduplicate broadcast messages by grouping those with the same
|
|
12
|
+
* sender, content, and approximate timestamp (within 1 second).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import type { Message } from '../../types';
|
|
17
|
+
import { deduplicateBroadcasts, getBroadcastKey } from './useBroadcastDedup';
|
|
18
|
+
|
|
19
|
+
// Helper to create test messages
|
|
20
|
+
function createMessage(
|
|
21
|
+
from: string,
|
|
22
|
+
to: string,
|
|
23
|
+
content: string,
|
|
24
|
+
options?: {
|
|
25
|
+
id?: string;
|
|
26
|
+
isBroadcast?: boolean;
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
channel?: string;
|
|
29
|
+
}
|
|
30
|
+
): Message {
|
|
31
|
+
return {
|
|
32
|
+
id: options?.id || `msg-${Math.random().toString(36).slice(2)}`,
|
|
33
|
+
from,
|
|
34
|
+
to,
|
|
35
|
+
content,
|
|
36
|
+
timestamp: options?.timestamp || new Date().toISOString(),
|
|
37
|
+
isBroadcast: options?.isBroadcast,
|
|
38
|
+
channel: options?.channel,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('Broadcast Deduplication', () => {
|
|
43
|
+
describe('deduplicateBroadcasts', () => {
|
|
44
|
+
it('should show broadcast message only once when delivered to multiple recipients', () => {
|
|
45
|
+
const timestamp = '2026-01-08T12:00:00.000Z';
|
|
46
|
+
|
|
47
|
+
// Same broadcast delivered to 3 different recipients
|
|
48
|
+
const messages: Message[] = [
|
|
49
|
+
createMessage('Alice', 'Agent1', 'Hello everyone!', {
|
|
50
|
+
id: 'delivery-1',
|
|
51
|
+
isBroadcast: true,
|
|
52
|
+
timestamp,
|
|
53
|
+
channel: 'general',
|
|
54
|
+
}),
|
|
55
|
+
createMessage('Alice', 'Agent2', 'Hello everyone!', {
|
|
56
|
+
id: 'delivery-2',
|
|
57
|
+
isBroadcast: true,
|
|
58
|
+
timestamp,
|
|
59
|
+
channel: 'general',
|
|
60
|
+
}),
|
|
61
|
+
createMessage('Alice', 'Agent3', 'Hello everyone!', {
|
|
62
|
+
id: 'delivery-3',
|
|
63
|
+
isBroadcast: true,
|
|
64
|
+
timestamp,
|
|
65
|
+
channel: 'general',
|
|
66
|
+
}),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
70
|
+
|
|
71
|
+
// Should only show one instance of the broadcast
|
|
72
|
+
expect(deduped).toHaveLength(1);
|
|
73
|
+
expect(deduped[0].content).toBe('Hello everyone!');
|
|
74
|
+
expect(deduped[0].from).toBe('Alice');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should preserve different broadcasts from the same sender', () => {
|
|
78
|
+
const timestamp1 = '2026-01-08T12:00:00.000Z';
|
|
79
|
+
const timestamp2 = '2026-01-08T12:01:00.000Z'; // 1 minute later
|
|
80
|
+
|
|
81
|
+
const messages: Message[] = [
|
|
82
|
+
// First broadcast delivered to 2 recipients
|
|
83
|
+
createMessage('Alice', 'Agent1', 'First message', {
|
|
84
|
+
id: 'delivery-1',
|
|
85
|
+
isBroadcast: true,
|
|
86
|
+
timestamp: timestamp1,
|
|
87
|
+
channel: 'general',
|
|
88
|
+
}),
|
|
89
|
+
createMessage('Alice', 'Agent2', 'First message', {
|
|
90
|
+
id: 'delivery-2',
|
|
91
|
+
isBroadcast: true,
|
|
92
|
+
timestamp: timestamp1,
|
|
93
|
+
channel: 'general',
|
|
94
|
+
}),
|
|
95
|
+
// Second broadcast delivered to 2 recipients
|
|
96
|
+
createMessage('Alice', 'Agent1', 'Second message', {
|
|
97
|
+
id: 'delivery-3',
|
|
98
|
+
isBroadcast: true,
|
|
99
|
+
timestamp: timestamp2,
|
|
100
|
+
channel: 'general',
|
|
101
|
+
}),
|
|
102
|
+
createMessage('Alice', 'Agent2', 'Second message', {
|
|
103
|
+
id: 'delivery-4',
|
|
104
|
+
isBroadcast: true,
|
|
105
|
+
timestamp: timestamp2,
|
|
106
|
+
channel: 'general',
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
111
|
+
|
|
112
|
+
// Should show both broadcasts, but only once each
|
|
113
|
+
expect(deduped).toHaveLength(2);
|
|
114
|
+
expect(deduped.map(m => m.content)).toContain('First message');
|
|
115
|
+
expect(deduped.map(m => m.content)).toContain('Second message');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should not deduplicate non-broadcast messages', () => {
|
|
119
|
+
const timestamp = '2026-01-08T12:00:00.000Z';
|
|
120
|
+
|
|
121
|
+
// Direct messages with same content should NOT be deduplicated
|
|
122
|
+
const messages: Message[] = [
|
|
123
|
+
createMessage('Alice', 'Bob', 'Hello!', {
|
|
124
|
+
id: 'dm-1',
|
|
125
|
+
isBroadcast: false,
|
|
126
|
+
timestamp,
|
|
127
|
+
}),
|
|
128
|
+
createMessage('Alice', 'Charlie', 'Hello!', {
|
|
129
|
+
id: 'dm-2',
|
|
130
|
+
isBroadcast: false,
|
|
131
|
+
timestamp,
|
|
132
|
+
}),
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
136
|
+
|
|
137
|
+
// Both DMs should remain (they're intentionally separate messages)
|
|
138
|
+
expect(deduped).toHaveLength(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should preserve message order after deduplication', () => {
|
|
142
|
+
const messages: Message[] = [
|
|
143
|
+
createMessage('Alice', 'Agent1', 'First', {
|
|
144
|
+
id: 'msg-1',
|
|
145
|
+
isBroadcast: true,
|
|
146
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
147
|
+
channel: 'general',
|
|
148
|
+
}),
|
|
149
|
+
createMessage('Alice', 'Agent2', 'First', {
|
|
150
|
+
id: 'msg-2',
|
|
151
|
+
isBroadcast: true,
|
|
152
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
153
|
+
channel: 'general',
|
|
154
|
+
}),
|
|
155
|
+
createMessage('Bob', 'Agent1', 'Second', {
|
|
156
|
+
id: 'msg-3',
|
|
157
|
+
isBroadcast: true,
|
|
158
|
+
timestamp: '2026-01-08T12:00:30.000Z',
|
|
159
|
+
channel: 'general',
|
|
160
|
+
}),
|
|
161
|
+
createMessage('Alice', 'Agent1', 'Third', {
|
|
162
|
+
id: 'msg-4',
|
|
163
|
+
isBroadcast: true,
|
|
164
|
+
timestamp: '2026-01-08T12:01:00.000Z',
|
|
165
|
+
channel: 'general',
|
|
166
|
+
}),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
170
|
+
|
|
171
|
+
expect(deduped).toHaveLength(3);
|
|
172
|
+
expect(deduped[0].content).toBe('First');
|
|
173
|
+
expect(deduped[1].content).toBe('Second');
|
|
174
|
+
expect(deduped[2].content).toBe('Third');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should handle mixed broadcast and direct messages', () => {
|
|
178
|
+
const messages: Message[] = [
|
|
179
|
+
// Broadcast delivered to 2 recipients
|
|
180
|
+
createMessage('Alice', 'Agent1', 'Broadcast', {
|
|
181
|
+
id: 'broadcast-1',
|
|
182
|
+
isBroadcast: true,
|
|
183
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
184
|
+
channel: 'general',
|
|
185
|
+
}),
|
|
186
|
+
createMessage('Alice', 'Agent2', 'Broadcast', {
|
|
187
|
+
id: 'broadcast-2',
|
|
188
|
+
isBroadcast: true,
|
|
189
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
190
|
+
channel: 'general',
|
|
191
|
+
}),
|
|
192
|
+
// Direct message
|
|
193
|
+
createMessage('Bob', 'Alice', 'DM to Alice', {
|
|
194
|
+
id: 'dm-1',
|
|
195
|
+
isBroadcast: false,
|
|
196
|
+
timestamp: '2026-01-08T12:00:30.000Z',
|
|
197
|
+
}),
|
|
198
|
+
// Another broadcast
|
|
199
|
+
createMessage('Charlie', 'Agent1', 'Another broadcast', {
|
|
200
|
+
id: 'broadcast-3',
|
|
201
|
+
isBroadcast: true,
|
|
202
|
+
timestamp: '2026-01-08T12:01:00.000Z',
|
|
203
|
+
channel: 'general',
|
|
204
|
+
}),
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
208
|
+
|
|
209
|
+
expect(deduped).toHaveLength(3);
|
|
210
|
+
expect(deduped.map(m => m.content)).toEqual([
|
|
211
|
+
'Broadcast',
|
|
212
|
+
'DM to Alice',
|
|
213
|
+
'Another broadcast',
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should keep first occurrence when deduplicating', () => {
|
|
218
|
+
const timestamp = '2026-01-08T12:00:00.000Z';
|
|
219
|
+
|
|
220
|
+
const messages: Message[] = [
|
|
221
|
+
createMessage('Alice', 'Agent1', 'Hello everyone!', {
|
|
222
|
+
id: 'first-delivery',
|
|
223
|
+
isBroadcast: true,
|
|
224
|
+
timestamp,
|
|
225
|
+
channel: 'general',
|
|
226
|
+
}),
|
|
227
|
+
createMessage('Alice', 'Agent2', 'Hello everyone!', {
|
|
228
|
+
id: 'second-delivery',
|
|
229
|
+
isBroadcast: true,
|
|
230
|
+
timestamp,
|
|
231
|
+
channel: 'general',
|
|
232
|
+
}),
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
236
|
+
|
|
237
|
+
// Should keep the first one (first-delivery)
|
|
238
|
+
expect(deduped).toHaveLength(1);
|
|
239
|
+
expect(deduped[0].id).toBe('first-delivery');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should handle empty message array', () => {
|
|
243
|
+
const deduped = deduplicateBroadcasts([]);
|
|
244
|
+
expect(deduped).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should handle messages with to="*" as broadcasts even without isBroadcast flag', () => {
|
|
248
|
+
const timestamp = '2026-01-08T12:00:00.000Z';
|
|
249
|
+
|
|
250
|
+
// Messages with to='*' but no isBroadcast flag (legacy format)
|
|
251
|
+
const messages: Message[] = [
|
|
252
|
+
createMessage('Alice', '*', 'Broadcast message', {
|
|
253
|
+
id: 'delivery-1',
|
|
254
|
+
timestamp,
|
|
255
|
+
channel: 'general',
|
|
256
|
+
}),
|
|
257
|
+
// Same message delivered to specific agent with isBroadcast flag
|
|
258
|
+
createMessage('Alice', 'Agent1', 'Broadcast message', {
|
|
259
|
+
id: 'delivery-2',
|
|
260
|
+
isBroadcast: true,
|
|
261
|
+
timestamp,
|
|
262
|
+
channel: 'general',
|
|
263
|
+
}),
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
267
|
+
|
|
268
|
+
// Should deduplicate - both represent the same broadcast
|
|
269
|
+
expect(deduped).toHaveLength(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should differentiate broadcasts with same content but different senders', () => {
|
|
273
|
+
const timestamp = '2026-01-08T12:00:00.000Z';
|
|
274
|
+
|
|
275
|
+
const messages: Message[] = [
|
|
276
|
+
createMessage('Alice', 'Agent1', 'Hello!', {
|
|
277
|
+
id: 'alice-1',
|
|
278
|
+
isBroadcast: true,
|
|
279
|
+
timestamp,
|
|
280
|
+
channel: 'general',
|
|
281
|
+
}),
|
|
282
|
+
createMessage('Bob', 'Agent1', 'Hello!', {
|
|
283
|
+
id: 'bob-1',
|
|
284
|
+
isBroadcast: true,
|
|
285
|
+
timestamp,
|
|
286
|
+
channel: 'general',
|
|
287
|
+
}),
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
291
|
+
|
|
292
|
+
// Different senders, so both should appear
|
|
293
|
+
expect(deduped).toHaveLength(2);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should handle timestamps within 1 second as same broadcast', () => {
|
|
297
|
+
// Timestamps within 1 second should be grouped
|
|
298
|
+
const messages: Message[] = [
|
|
299
|
+
createMessage('Alice', 'Agent1', 'Quick message', {
|
|
300
|
+
id: 'delivery-1',
|
|
301
|
+
isBroadcast: true,
|
|
302
|
+
timestamp: '2026-01-08T12:00:00.100Z',
|
|
303
|
+
channel: 'general',
|
|
304
|
+
}),
|
|
305
|
+
createMessage('Alice', 'Agent2', 'Quick message', {
|
|
306
|
+
id: 'delivery-2',
|
|
307
|
+
isBroadcast: true,
|
|
308
|
+
timestamp: '2026-01-08T12:00:00.500Z',
|
|
309
|
+
channel: 'general',
|
|
310
|
+
}),
|
|
311
|
+
createMessage('Alice', 'Agent3', 'Quick message', {
|
|
312
|
+
id: 'delivery-3',
|
|
313
|
+
isBroadcast: true,
|
|
314
|
+
timestamp: '2026-01-08T12:00:00.900Z',
|
|
315
|
+
channel: 'general',
|
|
316
|
+
}),
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
const deduped = deduplicateBroadcasts(messages);
|
|
320
|
+
|
|
321
|
+
// All within 1 second, same sender/content - should be 1 message
|
|
322
|
+
expect(deduped).toHaveLength(1);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('getBroadcastKey', () => {
|
|
327
|
+
it('should generate consistent keys for same sender/content/timestamp', () => {
|
|
328
|
+
const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
|
|
329
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
330
|
+
});
|
|
331
|
+
const msg2 = createMessage('Alice', 'Agent2', 'Hello', {
|
|
332
|
+
timestamp: '2026-01-08T12:00:00.500Z', // Same second
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(getBroadcastKey(msg1)).toBe(getBroadcastKey(msg2));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should generate different keys for different senders', () => {
|
|
339
|
+
const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
|
|
340
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
341
|
+
});
|
|
342
|
+
const msg2 = createMessage('Bob', 'Agent1', 'Hello', {
|
|
343
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should generate different keys for different content', () => {
|
|
350
|
+
const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
|
|
351
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
352
|
+
});
|
|
353
|
+
const msg2 = createMessage('Alice', 'Agent1', 'Goodbye', {
|
|
354
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2));
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should generate different keys for timestamps more than 1 second apart', () => {
|
|
361
|
+
const msg1 = createMessage('Alice', 'Agent1', 'Hello', {
|
|
362
|
+
timestamp: '2026-01-08T12:00:00.000Z',
|
|
363
|
+
});
|
|
364
|
+
const msg2 = createMessage('Alice', 'Agent1', 'Hello', {
|
|
365
|
+
timestamp: '2026-01-08T12:00:02.000Z', // 2 seconds later
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(getBroadcastKey(msg1)).not.toBe(getBroadcastKey(msg2));
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broadcast message deduplication utilities
|
|
3
|
+
*
|
|
4
|
+
* When a broadcast is sent (to='*'), the backend delivers it to each
|
|
5
|
+
* recipient separately. Each delivery gets a unique ID and is stored separately.
|
|
6
|
+
* In #general channel, this causes the same message to appear multiple times
|
|
7
|
+
* (once per recipient).
|
|
8
|
+
*
|
|
9
|
+
* This module provides utilities to deduplicate broadcast messages by grouping
|
|
10
|
+
* those with the same sender, content, and approximate timestamp.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useMemo } from 'react';
|
|
14
|
+
import type { Message } from '../../types';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if a message is a broadcast message.
|
|
18
|
+
* A message is considered a broadcast if:
|
|
19
|
+
* - isBroadcast flag is true, OR
|
|
20
|
+
* - to field is '*'
|
|
21
|
+
*/
|
|
22
|
+
function isBroadcastMessage(message: Message): boolean {
|
|
23
|
+
return message.isBroadcast === true || message.to === '*';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate a deduplication key for broadcast messages.
|
|
28
|
+
* Uses sender + content + timestamp bucket (1-second window).
|
|
29
|
+
*
|
|
30
|
+
* @param message The message to generate a key for
|
|
31
|
+
* @returns A string key for deduplication
|
|
32
|
+
*/
|
|
33
|
+
export function getBroadcastKey(message: Message): string {
|
|
34
|
+
const timestampBucket = Math.floor(new Date(message.timestamp).getTime() / 1000);
|
|
35
|
+
return `${message.from}:${timestampBucket}:${message.content}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Deduplicate broadcast messages.
|
|
40
|
+
*
|
|
41
|
+
* When a broadcast is sent, it gets delivered to each recipient separately,
|
|
42
|
+
* resulting in multiple stored messages with the same content. This function
|
|
43
|
+
* deduplicates them by grouping broadcasts with the same:
|
|
44
|
+
* - from (sender)
|
|
45
|
+
* - content (message body)
|
|
46
|
+
* - timestamp (within 1 second window)
|
|
47
|
+
*
|
|
48
|
+
* Non-broadcast messages (direct messages) are preserved unchanged.
|
|
49
|
+
* Message order is maintained, keeping the first occurrence of each broadcast.
|
|
50
|
+
*
|
|
51
|
+
* @param messages Array of messages to deduplicate
|
|
52
|
+
* @returns Deduplicated array with broadcast duplicates removed
|
|
53
|
+
*/
|
|
54
|
+
export function deduplicateBroadcasts(messages: Message[]): Message[] {
|
|
55
|
+
const seenBroadcastKeys = new Set<string>();
|
|
56
|
+
const result: Message[] = [];
|
|
57
|
+
|
|
58
|
+
for (const message of messages) {
|
|
59
|
+
// Non-broadcast messages pass through unchanged
|
|
60
|
+
if (!isBroadcastMessage(message)) {
|
|
61
|
+
result.push(message);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// For broadcasts, check if we've seen this key before
|
|
66
|
+
const key = getBroadcastKey(message);
|
|
67
|
+
if (!seenBroadcastKeys.has(key)) {
|
|
68
|
+
seenBroadcastKeys.add(key);
|
|
69
|
+
result.push(message);
|
|
70
|
+
}
|
|
71
|
+
// If key already seen, skip this duplicate
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Hook for using broadcast deduplication with React state.
|
|
79
|
+
* Uses useMemo to prevent unnecessary recalculations when messages haven't changed.
|
|
80
|
+
*
|
|
81
|
+
* @param messages Array of messages to deduplicate
|
|
82
|
+
* @returns Deduplicated messages
|
|
83
|
+
*/
|
|
84
|
+
export function useBroadcastDedup(messages: Message[]): Message[] {
|
|
85
|
+
return useMemo(() => deduplicateBroadcasts(messages), [messages]);
|
|
86
|
+
}
|