Skip to content

Commit 2490b60

Browse files
Odie: stop duplicating pre-escalation messages in escalated chats (#113945)
* Odie: stop duplicating pre-escalation messages in escalated chats An escalated chat is rebuilt by concatenating the Odie history with the Zendesk conversation. To survive a reconnect, the merge also carried over every message the user had sent, taken from the previous chat state — but that state is itself a merged list, so the carry-over picked up the Odie messages the same merge had just re-read from the Odie chat. Deduplication runs only within the Zendesk half, so the two copies never met and every message sent before escalation rendered twice. Carry over only messages the user sent through Zendesk, which is what the reconnect recovery was for: those are the ones the composer marks with a `temporary_id`, and the Odie half never has one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Apply suggestion from @escapemanuele --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7455448 commit 2490b60

2 files changed

Lines changed: 135 additions & 2 deletions

File tree

packages/odie-client/src/hooks/test/use-get-combined-chat.test.tsx

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ let mockIsChatLoaded = true;
1313
let mockConnectionStatus: string | undefined;
1414
let mockCurrentSupportInteraction: Record< string, unknown > | undefined;
1515
let mockConversation: { id: string; messages: Message[] } | null;
16+
let mockOdieChat: Record< string, unknown > | undefined;
1617
const mockGetZendeskConversation = jest.fn();
1718
const mockStartNewInteraction = jest.fn();
1819

@@ -43,7 +44,7 @@ jest.mock( '../use-logged-out-session', () => ( {
4344
jest.mock( '../../data', () => ( {
4445
useGetZendeskConversation: () => mockGetZendeskConversation,
4546
useManageSupportInteraction: () => ( { startNewInteraction: mockStartNewInteraction } ),
46-
useOdieChat: () => ( { data: undefined, isFetching: false } ),
47+
useOdieChat: () => ( { data: mockOdieChat, isFetching: false } ),
4748
} ) );
4849

4950
jest.mock( '../../data/use-current-support-interaction', () => ( {
@@ -100,9 +101,132 @@ beforeEach( () => {
100101
mockConnectionStatus = undefined;
101102
mockCurrentSupportInteraction = undefined;
102103
mockConversation = null;
104+
mockOdieChat = undefined;
103105
mockGetZendeskConversation.mockImplementation( () => Promise.resolve( mockConversation ) );
104106
} );
105107

108+
describe( 'useGetCombinedChat — merging the Odie and Zendesk halves', () => {
109+
const userMessage = ( id: number, content: string ): Message =>
110+
( {
111+
content,
112+
role: 'user',
113+
type: 'message',
114+
message_id: id,
115+
metadata: {},
116+
} ) as unknown as Message;
117+
118+
const countMessages = ( messages: Message[], content: string ) =>
119+
messages.filter( ( message ) => message.content === content ).length;
120+
121+
it( 'keeps a pre-escalation user message single after the conversation is re-fetched', async () => {
122+
mockCurrentSupportInteraction = {
123+
uuid: 'int-1',
124+
conversationId: 'conv-1',
125+
odieId: 42,
126+
status: 'open',
127+
};
128+
mockOdieChat = {
129+
odieId: 42,
130+
wpcomUserId: 99,
131+
conversationId: null,
132+
provider: 'odie',
133+
status: 'loaded',
134+
messages: [ userMessage( 10, 'How do I backup my site?' ) ],
135+
};
136+
mockConversation = { id: 'conv-1', messages: [ agentMessage( 1, 'Happiness Engineer here' ) ] };
137+
138+
const { result, rerender } = renderCombinedChat();
139+
140+
await waitFor( () => {
141+
expect( mockGetZendeskConversation ).toHaveBeenCalledWith( 'conv-1' );
142+
expect(
143+
countMessages( result.current.mainChatState.messages, 'How do I backup my site?' )
144+
).toBe( 1 );
145+
} );
146+
147+
// Any reconnect re-runs the merge against the previous chat state. The second agent
148+
// message marks when that re-merge has landed.
149+
mockConversation = {
150+
id: 'conv-1',
151+
messages: [ agentMessage( 1, 'Happiness Engineer here' ), agentMessage( 2, 'still here' ) ],
152+
};
153+
act( () => {
154+
mockConnectionStatus = 'connected';
155+
} );
156+
rerender();
157+
158+
await waitFor( () => {
159+
expect( result.current.mainChatState.messages.some( ( m ) => m.message_id === 2 ) ).toBe(
160+
true
161+
);
162+
} );
163+
164+
expect(
165+
countMessages( result.current.mainChatState.messages, 'How do I backup my site?' )
166+
).toBe( 1 );
167+
} );
168+
169+
it( 'keeps a queued Zendesk message that the re-fetched conversation does not have yet', async () => {
170+
mockCurrentSupportInteraction = {
171+
uuid: 'int-1',
172+
conversationId: 'conv-1',
173+
odieId: 42,
174+
status: 'open',
175+
};
176+
mockOdieChat = {
177+
odieId: 42,
178+
wpcomUserId: 99,
179+
conversationId: null,
180+
provider: 'odie',
181+
status: 'loaded',
182+
messages: [],
183+
};
184+
mockConversation = { id: 'conv-1', messages: [ agentMessage( 1, 'Happiness Engineer here' ) ] };
185+
186+
const { result, rerender } = renderCombinedChat();
187+
188+
await waitFor( () => {
189+
expect( result.current.mainChatState.messages.some( ( m ) => m.message_id === 1 ) ).toBe(
190+
true
191+
);
192+
} );
193+
194+
// The user sends a message that has not reached the server yet: it lives only in the
195+
// chat state, carrying the temporary id the composer attaches for Zendesk sends.
196+
act( () => {
197+
result.current.setMainChatState( ( chat ) => ( {
198+
...chat,
199+
messages: [
200+
...chat.messages,
201+
{
202+
content: 'my site is down',
203+
role: 'user',
204+
type: 'message',
205+
metadata: { temporary_id: 'temp-1' },
206+
} as unknown as Message,
207+
],
208+
} ) );
209+
} );
210+
211+
mockConversation = {
212+
id: 'conv-1',
213+
messages: [ agentMessage( 1, 'Happiness Engineer here' ), agentMessage( 2, 'still here' ) ],
214+
};
215+
act( () => {
216+
mockConnectionStatus = 'connected';
217+
} );
218+
rerender();
219+
220+
await waitFor( () => {
221+
expect( result.current.mainChatState.messages.some( ( m ) => m.message_id === 2 ) ).toBe(
222+
true
223+
);
224+
} );
225+
226+
expect( countMessages( result.current.mainChatState.messages, 'my site is down' ) ).toBe( 1 );
227+
} );
228+
} );
229+
106230
describe( 'useGetCombinedChat — message recovery on Smooch re-init', () => {
107231
it( 're-fetches the conversation and recovers gap messages when isChatLoaded flips false → true', async () => {
108232
mockCurrentSupportInteraction = {

packages/odie-client/src/hooks/use-get-combined-chat.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ function isEqual( message1: Message, message2: Message ) {
2727
return message1Id && message1Id === message2Id;
2828
}
2929

30+
/**
31+
* A user message sent through Zendesk: only those carry a `temporary_id`, so Odie messages don't match.
32+
* @param message - The message to check.
33+
* @returns Whether the message is a Zendesk message sent by the user.
34+
*/
35+
function isQueuedZendeskMessage( message: Message ) {
36+
return message.role === 'user' && !! message.metadata?.temporary_id;
37+
}
38+
3039
/**
3140
* Deduplicate Zendesk messages by their temporary id. During connection recovery, some duplication can occur.
3241
* @param messages - The messages to deduplicate.
@@ -209,7 +218,7 @@ export const useGetCombinedChat = (
209218
...( deduplicateZDMessages( [
210219
// During connection recovery, the user queued messages can be deleted. This ensure they remain. And `deduplicateZDMessages` takes of duplication.
211220
...( isSameConversation
212-
? prevChat.messages.filter( ( message ) => message.role === 'user' )
221+
? prevChat.messages.filter( isQueuedZendeskMessage )
213222
: [] ),
214223
...conversation.messages,
215224
] ) as Message[] ),

0 commit comments

Comments
 (0)