fix(chat): dedupe optimistic user message against Gateway-prefixed echo (#887)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -230,8 +230,31 @@ function normalizeStreamingMessage(message: unknown): unknown {
|
|||||||
: rawMessage;
|
: rawMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip Gateway-injected metadata that does NOT exist on the renderer's
|
||||||
|
* optimistic user message but is echoed back when the Gateway persists it:
|
||||||
|
* - leading timestamp `[Wed 2026-04-22 10:30 GMT+8] `
|
||||||
|
* - `[message_id: uuid]` tags sprinkled throughout the text
|
||||||
|
* - `[media attached: path (mime) | path]` references appended when the
|
||||||
|
* renderer sends attachments via `chat:sendWithMedia`
|
||||||
|
* - Gateway-injected "Conversation info (untrusted metadata): ..." blocks
|
||||||
|
*
|
||||||
|
* Keeping this aligned with `cleanUserText` in `pages/Chat/message-utils.ts`
|
||||||
|
* is important: the user bubble renders the cleaned text, so the comparison
|
||||||
|
* used to dedupe optimistic vs server echoes must operate on the same
|
||||||
|
* cleaned form — otherwise the same visible message renders twice.
|
||||||
|
*/
|
||||||
|
function stripGatewayUserMetadata(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
|
||||||
|
.replace(/\s*\[media attached:[^\]]*\]/g, '')
|
||||||
|
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
|
||||||
|
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||||
|
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeComparableUserText(content: unknown): string {
|
function normalizeComparableUserText(content: unknown): string {
|
||||||
return getMessageText(content)
|
return stripGatewayUserMetadata(getMessageText(content))
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,8 +128,31 @@ function normalizeStreamingMessage(message: unknown): unknown {
|
|||||||
: rawMessage;
|
: rawMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip Gateway-injected metadata that does NOT exist on the renderer's
|
||||||
|
* optimistic user message but is echoed back when the Gateway persists it:
|
||||||
|
* - leading timestamp `[Wed 2026-04-22 10:30 GMT+8] `
|
||||||
|
* - `[message_id: uuid]` tags sprinkled throughout the text
|
||||||
|
* - `[media attached: path (mime) | path]` references appended when the
|
||||||
|
* renderer sends attachments via `chat:sendWithMedia`
|
||||||
|
* - Gateway-injected "Conversation info (untrusted metadata): ..." blocks
|
||||||
|
*
|
||||||
|
* Keeping this aligned with `cleanUserText` in `pages/Chat/message-utils.ts`
|
||||||
|
* is important: the user bubble renders the cleaned text, so the comparison
|
||||||
|
* used to dedupe optimistic vs server echoes must operate on the same
|
||||||
|
* cleaned form — otherwise the same visible message renders twice.
|
||||||
|
*/
|
||||||
|
function stripGatewayUserMetadata(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
|
||||||
|
.replace(/\s*\[media attached:[^\]]*\]/g, '')
|
||||||
|
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
|
||||||
|
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||||
|
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeComparableUserText(content: unknown): string {
|
function normalizeComparableUserText(content: unknown): string {
|
||||||
return getMessageText(content)
|
return stripGatewayUserMetadata(getMessageText(content))
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
68
tests/unit/chat-optimistic-match.test.ts
Normal file
68
tests/unit/chat-optimistic-match.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { matchesOptimisticUserMessage } from '@/stores/chat/helpers';
|
||||||
|
|
||||||
|
describe('matchesOptimisticUserMessage', () => {
|
||||||
|
it('matches when text is identical', () => {
|
||||||
|
const optimistic = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const;
|
||||||
|
const candidate = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const;
|
||||||
|
|
||||||
|
expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches when Gateway prefixes a weekday/timestamp prefix on the echoed user message', () => {
|
||||||
|
const optimistic = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const;
|
||||||
|
const candidate = {
|
||||||
|
role: 'user',
|
||||||
|
content: '[Wed 2026-04-22 10:30 GMT+8] run github1',
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches when the server appends [media attached: ...] to the echoed user message', () => {
|
||||||
|
const optimistic = {
|
||||||
|
role: 'user',
|
||||||
|
content: 'Describe this image',
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
_attachedFiles: [
|
||||||
|
{
|
||||||
|
fileName: 'shot.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
fileSize: 123,
|
||||||
|
preview: null,
|
||||||
|
filePath: '/tmp/shot.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
const candidate = {
|
||||||
|
role: 'user',
|
||||||
|
content: 'Describe this image\n\n[media attached: /tmp/shot.png (image/png) | /tmp/shot.png]',
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches when the server strips a [message_id: ...] tag from the user message', () => {
|
||||||
|
const optimistic = { role: 'user', content: 'hello world', timestamp: 1_700_000_000 } as const;
|
||||||
|
const candidate = {
|
||||||
|
role: 'user',
|
||||||
|
content: 'hello world [message_id: 11111111-2222-3333-4444-555555555555]',
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects unrelated user messages', () => {
|
||||||
|
const optimistic = { role: 'user', content: 'run github1', timestamp: 1_700_000_000 } as const;
|
||||||
|
const candidate = {
|
||||||
|
role: 'user',
|
||||||
|
content: '[Wed 2026-04-22 10:30 GMT+8] completely different text',
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user