From 07edd6e9b97075bd8ec983482e74011c4cf04c48 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 7 May 2026 12:10:04 +0300 Subject: [PATCH] fix: stabilize chat turn projection --- .../chat/lib/turns/projectTurnRecords.test.ts | 89 +++++++++++++++++++ .../chat/lib/turns/projectTurnRecords.ts | 66 +++++++------- packages/ui/src/lib/opencode/client.ts | 55 ++++++++++-- 3 files changed, 168 insertions(+), 42 deletions(-) create mode 100644 packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts new file mode 100644 index 00000000..2806a42c --- /dev/null +++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from 'bun:test'; +import type { Message, Part } from '@opencode-ai/sdk/v2'; +import { projectTurnRecords } from './projectTurnRecords'; +import type { ChatMessageEntry } from './types'; + +function createMessageEntry({ + id, + role, + parentID, + createdAt, +}: { + id: string; + role: 'user' | 'assistant' | 'system'; + parentID?: string; + createdAt: number; +}): ChatMessageEntry { + return { + info: { + id, + role, + ...(parentID ? { parentID } : {}), + time: { created: createdAt }, + } as Message, + parts: [] as Part[], + }; +} + +describe('projectTurnRecords', () => { + test('groups assistant replies under their parent user turn', () => { + const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 }); + const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 }); + + const projection = projectTurnRecords([user, assistant]); + + expect(projection.turns).toHaveLength(1); + expect(projection.turns[0]?.turnId).toBe('u1'); + expect(projection.turns[0]?.assistantMessageIds).toEqual(['a1']); + expect(projection.ungroupedMessageIds.size).toBe(0); + }); + + test('keeps out-of-order assistant replies attached to their parent user turn', () => { + const user1 = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 }); + const assistant1 = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 }); + const assistant2 = createMessageEntry({ id: 'a2', role: 'assistant', parentID: 'u2', createdAt: 4 }); + const user2 = createMessageEntry({ id: 'u2', role: 'user', createdAt: 3 }); + + const projection = projectTurnRecords([user1, assistant1, assistant2, user2]); + + expect(projection.turns).toHaveLength(2); + expect(projection.turns[0]?.turnId).toBe('u1'); + expect(projection.turns[0]?.assistantMessageIds).toEqual(['a1']); + expect(projection.turns[1]?.turnId).toBe('u2'); + expect(projection.turns[1]?.assistantMessageIds).toEqual(['a2']); + expect(projection.ungroupedMessageIds.size).toBe(0); + }); + + test('does not render assistant replies while their parent user turn is missing', () => { + const user1 = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 }); + const assistant1 = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 }); + const assistant2 = createMessageEntry({ id: 'a2', role: 'assistant', parentID: 'u2', createdAt: 4 }); + + const projection = projectTurnRecords([user1, assistant1, assistant2]); + + expect(projection.turns).toHaveLength(1); + expect(projection.turns[0]?.turnId).toBe('u1'); + expect(projection.turns[0]?.assistantMessageIds).toEqual(['a1']); + expect(projection.ungroupedMessageIds.has('a2')).toBe(false); + expect(projection.indexes.messageToTurnId.has('a2')).toBe(false); + }); + + test('does not render orphan assistant messages as standalone ungrouped entries', () => { + const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'missing-user', createdAt: 1 }); + + const projection = projectTurnRecords([assistant]); + + expect(projection.turns).toHaveLength(0); + expect(projection.ungroupedMessageIds.has('a1')).toBe(false); + expect(projection.indexes.messageToTurnId.has('a1')).toBe(false); + }); + + test('keeps non-assistant orphan messages available as ungrouped entries', () => { + const system = createMessageEntry({ id: 's1', role: 'system', createdAt: 1 }); + + const projection = projectTurnRecords([system]); + + expect(projection.turns).toHaveLength(0); + expect(projection.ungroupedMessageIds.has('s1')).toBe(true); + }); +}); diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts index 33da93b1..7358de2e 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts @@ -102,46 +102,47 @@ export const projectTurnRecords = ( const turns: TurnRecord[] = []; const turnByUserId = new Map(); const groupedMessageIds = new Set(); - let currentTurn: TurnRecord | undefined; messages.forEach((message, index) => { const role = resolveMessageRole(message); - if (role === 'user') { - const turnId = message.info.id; - const turn: TurnRecord = { - turnId, - userMessageId: message.info.id, - userMessage: message, - headerMessageId: undefined, - messages: [createTurnMessageRecord(message, index)], - assistantMessageIds: [], - assistantMessages: [], - activityParts: [], - activitySegments: [], - summary: {}, - summaryText: undefined, - hasTools: false, - hasReasoning: false, - diffStats: undefined, - stream: { - isStreaming: false, - isRetrying: false, - }, - }; - turns.push(turn); - turnByUserId.set(turn.userMessageId, turn); - groupedMessageIds.add(message.info.id); - currentTurn = turn; + if (role !== 'user') { return; } + const turnId = message.info.id; + const turn: TurnRecord = { + turnId, + userMessageId: message.info.id, + userMessage: message, + headerMessageId: undefined, + messages: [createTurnMessageRecord(message, index)], + assistantMessageIds: [], + assistantMessages: [], + activityParts: [], + activitySegments: [], + summary: {}, + summaryText: undefined, + hasTools: false, + hasReasoning: false, + diffStats: undefined, + stream: { + isStreaming: false, + isRetrying: false, + }, + }; + turns.push(turn); + turnByUserId.set(turn.userMessageId, turn); + groupedMessageIds.add(message.info.id); + }); + + messages.forEach((message, index) => { + const role = resolveMessageRole(message); if (role !== 'assistant') { return; } const parentId = getMessageParentId(message); - const parentTurn = parentId ? turnByUserId.get(parentId) : undefined; - const targetTurn = parentTurn ?? currentTurn; + const targetTurn = parentId ? turnByUserId.get(parentId) : undefined; if (!targetTurn) { return; } @@ -153,10 +154,6 @@ export const projectTurnRecords = ( targetTurn.headerMessageId = message.info.id; } groupedMessageIds.add(message.info.id); - - if (!parentTurn) { - currentTurn = targetTurn; - } }); turns.forEach((turn) => { @@ -185,6 +182,9 @@ export const projectTurnRecords = ( const projection = projectTurnIndexes(turns); const ungroupedMessageIds = new Set(); messages.forEach((message) => { + if (resolveMessageRole(message) === 'assistant') { + return; + } if (!groupedMessageIds.has(message.info.id)) { ungroupedMessageIds.add(message.info.id); } diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 0df20d1c..ab8f03ad 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -27,6 +27,45 @@ import { // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api"; const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//; +const ID_RANDOM_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const ID_RANDOM_LENGTH = 14; + +let lastIdTimestamp = 0; +let idCounter = 0; + +const randomBase62 = (length: number): string => { + const bytes = new Uint8Array(length); + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + + let result = ""; + for (let index = 0; index < length; index += 1) { + result += ID_RANDOM_CHARS[bytes[index] % ID_RANDOM_CHARS.length]; + } + return result; +}; + +const ascendingId = (prefix: "msg"): string => { + const timestamp = Date.now(); + if (timestamp !== lastIdTimestamp) { + lastIdTimestamp = timestamp; + idCounter = 0; + } + idCounter += 1; + + const sortable = BigInt(timestamp) * BigInt(0x1000) + BigInt(idCounter); + const timeBytes = new Uint8Array(6); + for (let index = 0; index < 6; index += 1) { + timeBytes[index] = Number((sortable >> BigInt(40 - 8 * index)) & BigInt(0xff)); + } + const hex = Array.from(timeBytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${prefix}_${hex}${randomBase62(ID_RANDOM_LENGTH)}`; +}; const isRetryableFetchError = (error: unknown): boolean => { if (error instanceof DOMException && error.name === 'AbortError') return true; @@ -631,10 +670,9 @@ class OpencodeService { retryCount?: number; }; }): Promise { - // Generate a temporary client-side ID for optimistic UI - // This ID won't be sent to the server - server will generate its own - const baseTimestamp = Date.now(); - const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`; + // Reuse one client-side message ID across retries. The server accepts this + // as the real user message ID, making ambiguous network retries idempotent. + const messageId = params.messageId ?? ascendingId("msg"); // Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts const parts: Array = []; @@ -757,7 +795,7 @@ class OpencodeService { }, agent: params.agent, variant: params.variant, - ...(params.messageId ? { messageID: params.messageId } : {}), + messageID: messageId, ...(params.format ? { format: params.format } : {}), parts, }), @@ -778,7 +816,7 @@ class OpencodeService { if (response.ok) { recordProviderSuccess(params.providerID); - return tempMessageId; + return messageId; } if (shouldRetry(params.providerID, response.status, attempt)) { @@ -817,8 +855,7 @@ class OpencodeService { files?: Array; messageId?: string; }): Promise { - const baseTimestamp = Date.now(); - const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`; + const tempMessageId = params.messageId ?? ascendingId("msg"); const parts: FilePartInput[] = []; if (params.files && params.files.length > 0) { @@ -840,7 +877,7 @@ class OpencodeService { ...(params.agent ? { agent: params.agent } : {}), ...(params.variant ? { variant: params.variant } : {}), ...(parts.length > 0 ? { parts } : {}), - ...(params.messageId ? { messageID: params.messageId } : {}), + messageID: tempMessageId, }; const response = await fetch(url.toString(), {