diff --git a/apps/desktop/src/app/chat/runtime-repository.test.ts b/apps/desktop/src/app/chat/runtime-repository.test.ts new file mode 100644 index 00000000000..d7eec0e34f0 --- /dev/null +++ b/apps/desktop/src/app/chat/runtime-repository.test.ts @@ -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 + +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' + ]) + }) +}) diff --git a/apps/desktop/src/app/chat/runtime-repository.ts b/apps/desktop/src/app/chat/runtime-repository.ts index 3dad84dac13..39b4d0c1d18 100644 --- a/apps/desktop/src/app/chat/runtime-repository.ts +++ b/apps/desktop/src/app/chat/runtime-repository.ts @@ -30,10 +30,22 @@ export function useRuntimeMessageRepository(messages: ChatMessage[]): ExportedMe return useMemo(() => { const items: { message: ThreadMessage; parentId: string | null }[] = [] const branchParentByGroup = new Map() + const seenIds = new Set() 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) { diff --git a/apps/desktop/src/lib/inflight-turn-journal.ts b/apps/desktop/src/lib/inflight-turn-journal.ts index e93a0f09754..2e4d0cb42f1 100644 --- a/apps/desktop/src/lib/inflight-turn-journal.ts +++ b/apps/desktop/src/lib/inflight-turn-journal.ts @@ -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 }