diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 574f561..4d6e6d7 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -230,8 +230,31 @@ function normalizeStreamingMessage(message: unknown): unknown { : 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 { - return getMessageText(content) + return stripGatewayUserMetadata(getMessageText(content)) .replace(/\s+/g, ' ') .trim(); } diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index b317cc0..c3068f6 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -128,8 +128,31 @@ function normalizeStreamingMessage(message: unknown): unknown { : 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 { - return getMessageText(content) + return stripGatewayUserMetadata(getMessageText(content)) .replace(/\s+/g, ' ') .trim(); } diff --git a/tests/unit/chat-optimistic-match.test.ts b/tests/unit/chat-optimistic-match.test.ts new file mode 100644 index 0000000..c7dff94 --- /dev/null +++ b/tests/unit/chat-optimistic-match.test.ts @@ -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); + }); +});