From 14790234ff7081cda3bb7664f84da813110baac7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Jul 2026 02:03:05 -0500 Subject: [PATCH] fix(desktop): stop a duplicate message id from crashing the workspace pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repeated id in the transcript reached assistant-ui's MessageRepository, which throws on the second link and takes the whole workspace pane down to the contribution error boundary — the pane the user is actually working in. Dedupe where the repository export is built, so no upstream transcript bug can crash the pane, and close the journal merge path that produced one: a resume that replays a still-journaled turn appended rows the base already held by id. --- .../src/app/chat/runtime-repository.test.ts | 58 +++++++++++++++++++ .../src/app/chat/runtime-repository.ts | 12 ++++ apps/desktop/src/lib/inflight-turn-journal.ts | 20 ++++++- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/runtime-repository.test.ts 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 }