fix(native-chat): handle reordering with timestampless transcripts (#10050)

Grok transcripts carry no timestamps. Previous logic excluded these rows
from matching, leaving pending sends and launch prompts unmatched and
causing the seeded bubble to appear rank-pinned at the list tail — which
reads as conversation reordering.

Now pending sends, launch prompts, and their pruning rules treat null
timestamps as matching-eligible, allowing echo suppression and proper
cleanup of delivered messages.
This commit is contained in:
Jinjing 2026-07-22 19:48:55 -07:00 committed by GitHub
parent 099003188d
commit bae29fd1e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 40 additions and 3 deletions

View File

@ -134,6 +134,16 @@ describe('prunePendingSends', () => {
expect(prunePendingSends(pending, [oldUser, oldAnswer])).toEqual(pending)
})
it('prunes a first send against a timestampless transcript turn (grok)', () => {
const pending = [{ ...pendingOf('p1', 'rename it'), afterMessageId: null }]
const transcript = [
{ ...userMessage('u1', 'rename it'), timestamp: null },
{ ...assistantMessage('a1', 'done'), timestamp: null }
]
expect(prunePendingSends(pending, transcript)).toEqual([])
})
it('prunes only one of two identical pending sends for one completed turn', () => {
const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')]
expect(
@ -246,6 +256,14 @@ describe('pendingSendsAsMessages', () => {
expect(prunePendingSends(pending, remoteTranscript)).toEqual([])
})
it('hides a first send while its timestampless transcript turn is visible (grok)', () => {
const pending = [{ ...pendingOf('p1', 'rename it'), afterMessageId: null }]
expect(
pendingSendsAsMessages(pending, [{ ...userMessage('u1', 'rename it'), timestamp: null }])
).toEqual([])
})
it('hides only one of two identical pending sends for one real user turn', () => {
const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')]
expect(pendingSendsAsMessages(pending, [userMessage('u1', 'repeat')]).map((m) => m.id)).toEqual(
@ -340,6 +358,20 @@ describe('launchPromptAsMessage', () => {
).toBe(true)
})
// Grok transcripts carry no timestamps; before the null-matchable rule the
// seeded bubble was never hidden or pruned and sat rank-pinned at the list
// tail forever, reading as the conversation reordering.
it('hides and prunes the launch prompt against a timestampless transcript (grok)', () => {
const entry = { tabId: 'tab-1', agent: 'grok' as const, text: 'rename it', createdAt: 42 }
const transcript = [
{ ...userMessage('u1', 'rename it'), timestamp: null },
{ ...assistantMessage('a1', 'done'), timestamp: null }
]
expect(launchPromptAsMessage(entry, transcript)).toBeNull()
expect(shouldPruneLaunchPrompt(entry, transcript)).toBe(true)
})
it('does not bind a launch prompt to an older identical completed turn', () => {
const entry = {
tabId: 'tab-1',

View File

@ -111,8 +111,11 @@ function messageIsAfterPendingTimestamp(
message: NativeChatMessage,
pending: NativeChatPendingSend
): boolean {
// Why: some transcripts (e.g. Grok) never carry timestamps. Excluding their
// rows would make the echo unmatchable forever, stranding a rank-pinned
// bubble at the list tail — which reads as the conversation reordering.
if (message.timestamp === null) {
return false
return true
}
const boundary = nativeChatPendingMatchingAfter(pending)
// A transcript-clock boundary describes an existing message, so exclude ties.
@ -231,9 +234,11 @@ export function launchPromptAsMessage(
if (!entry) {
return null
}
// Why: a launch prompt seeds a brand-new session, so a matching user turn
// with no timestamp (e.g. Grok transcripts) can only be its own delivery.
const represented = matchingNativeChatUserContentCounts(
existingMessages.filter(
(message) => message.timestamp !== null && message.timestamp >= entry.createdAt
(message) => message.timestamp === null || message.timestamp >= entry.createdAt
)
)
if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) {
@ -256,7 +261,7 @@ export function shouldPruneLaunchPrompt(
messages: NativeChatMessage[]
): boolean {
const relevant = messages.filter(
(message) => message.timestamp !== null && message.timestamp >= entry.createdAt
(message) => message.timestamp === null || message.timestamp >= entry.createdAt
)
return (
(advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0