Merge pull request #73178 from NousResearch/bb/dupe-message-id

fix(desktop): stop a duplicate message id from crashing the workspace pane
This commit is contained in:
brooklyn! 2026-07-28 02:19:12 -05:00 committed by GitHub
commit a9c9467dd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 88 additions and 2 deletions

View file

@ -0,0 +1,58 @@
import { MessageRepository } from '@assistant-ui/core/internal'
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { syncRepositoryIncrementally } from '@/lib/incremental-external-store-runtime'
import { useRuntimeMessageRepository } from './runtime-repository'
const text = (id: string, role: ChatMessage['role'], body: string): ChatMessage => ({
id,
role,
parts: [{ type: 'text', text: body }]
})
/** The repository the runtime drives — it throws on a duplicate link. */
const feedToRepository = (repository: ExportedRepository) => {
const runtime = { repository: new MessageRepository() } as unknown as Parameters<
typeof syncRepositoryIncrementally
>[0]
return syncRepositoryIncrementally(runtime, repository)
}
type ExportedRepository = ReturnType<typeof useRuntimeMessageRepository>
describe('useRuntimeMessageRepository', () => {
it('emits each id once when the transcript repeats one', () => {
const { result } = renderHook(() =>
useRuntimeMessageRepository([
text('user-1', 'user', 'hi'),
text('assistant-1', 'assistant', 'hello'),
text('user-1', 'user', 'hi')
])
)
const ids = result.current.messages.map(item => item.message.id)
expect(ids).toEqual(['user-1', 'assistant-1'])
})
it('builds a repository the runtime can link without throwing', () => {
const { result } = renderHook(() =>
useRuntimeMessageRepository([
text('user-1', 'user', 'hi'),
text('assistant-stream-1', 'assistant', 'partial'),
text('assistant-stream-1', 'assistant', 'partial'),
text('user-2', 'user', 'more')
])
)
expect(feedToRepository(result.current).map(item => item.id)).toEqual([
'user-1',
'assistant-stream-1',
'user-2'
])
})
})

View file

@ -30,10 +30,22 @@ export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMe
return useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
const seenIds = new Set<string>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of coalesceToolOnlyAssistants(messages, toolMergeCacheRef.current)) {
// A repeated id is a transcript bug upstream, but it must not reach the
// repository: MessageRepository throws on the second link ("A message
// with the same id already exists in the parent tree") and takes the
// whole workspace pane down with it. Keep the first occurrence — the
// later copy carries the same id, so it is the row we already rendered.
if (seenIds.has(message.id)) {
continue
}
seenIds.add(message.id)
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {

View file

@ -291,6 +291,16 @@ function overlayProjectionRow(projection: ChatMessage, journalRow: ChatMessage):
return { ...merged, parts }
}
/** Rows the base transcript doesn't already hold by id. The journal and the
* base can both carry the same row (a resume that replays a still-journaled
* turn), and appending it twice puts a duplicate id in the transcript
* which assistant-ui's MessageRepository rejects by throwing. */
function withoutBaseIds(rows: ChatMessage[], baseMessages: ChatMessage[]): ChatMessage[] {
const baseIds = new Set(baseMessages.map(message => message.id))
return rows.filter(row => !baseIds.has(row.id))
}
export function mergeInFlightMessages(
baseMessages: ChatMessage[],
tailMessages: ChatMessage[],
@ -321,7 +331,13 @@ export function mergeInFlightMessages(
// append the whole tail.
const streamId = lastJournalRow?.id ?? null
return { applied: true, caughtUp: false, messages: [...baseMessages, ...tail], streamId, turnStartedAt: null }
return {
applied: true,
caughtUp: false,
messages: [...baseMessages, ...withoutBaseIds(tail, baseMessages)],
streamId,
turnStartedAt: null
}
}
const afterUser = baseMessages.slice(matchingUserIndex + 1)
@ -350,7 +366,7 @@ export function mergeInFlightMessages(
return {
applied: true,
caughtUp: false,
messages: [...baseMessages, ...tailAssistants],
messages: [...baseMessages, ...withoutBaseIds(tailAssistants, baseMessages)],
streamId,
turnStartedAt: null
}