fix: stabilize chat turn projection
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,46 +102,47 @@ export const projectTurnRecords = (
|
|||||||
const turns: TurnRecord[] = [];
|
const turns: TurnRecord[] = [];
|
||||||
const turnByUserId = new Map<string, TurnRecord>();
|
const turnByUserId = new Map<string, TurnRecord>();
|
||||||
const groupedMessageIds = new Set<string>();
|
const groupedMessageIds = new Set<string>();
|
||||||
let currentTurn: TurnRecord | undefined;
|
|
||||||
|
|
||||||
messages.forEach((message, index) => {
|
messages.forEach((message, index) => {
|
||||||
const role = resolveMessageRole(message);
|
const role = resolveMessageRole(message);
|
||||||
if (role === 'user') {
|
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;
|
|
||||||
return;
|
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') {
|
if (role !== 'assistant') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentId = getMessageParentId(message);
|
const parentId = getMessageParentId(message);
|
||||||
const parentTurn = parentId ? turnByUserId.get(parentId) : undefined;
|
const targetTurn = parentId ? turnByUserId.get(parentId) : undefined;
|
||||||
const targetTurn = parentTurn ?? currentTurn;
|
|
||||||
if (!targetTurn) {
|
if (!targetTurn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,10 +154,6 @@ export const projectTurnRecords = (
|
|||||||
targetTurn.headerMessageId = message.info.id;
|
targetTurn.headerMessageId = message.info.id;
|
||||||
}
|
}
|
||||||
groupedMessageIds.add(message.info.id);
|
groupedMessageIds.add(message.info.id);
|
||||||
|
|
||||||
if (!parentTurn) {
|
|
||||||
currentTurn = targetTurn;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
turns.forEach((turn) => {
|
turns.forEach((turn) => {
|
||||||
@@ -185,6 +182,9 @@ export const projectTurnRecords = (
|
|||||||
const projection = projectTurnIndexes(turns);
|
const projection = projectTurnIndexes(turns);
|
||||||
const ungroupedMessageIds = new Set<string>();
|
const ungroupedMessageIds = new Set<string>();
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
|
if (resolveMessageRole(message) === 'assistant') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!groupedMessageIds.has(message.info.id)) {
|
if (!groupedMessageIds.has(message.info.id)) {
|
||||||
ungroupedMessageIds.add(message.info.id);
|
ungroupedMessageIds.add(message.info.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,45 @@ import {
|
|||||||
// Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments
|
// 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 DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api";
|
||||||
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
|
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 => {
|
const isRetryableFetchError = (error: unknown): boolean => {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
||||||
@@ -631,10 +670,9 @@ class OpencodeService {
|
|||||||
retryCount?: number;
|
retryCount?: number;
|
||||||
};
|
};
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
// Generate a temporary client-side ID for optimistic UI
|
// Reuse one client-side message ID across retries. The server accepts this
|
||||||
// This ID won't be sent to the server - server will generate its own
|
// as the real user message ID, making ambiguous network retries idempotent.
|
||||||
const baseTimestamp = Date.now();
|
const messageId = params.messageId ?? ascendingId("msg");
|
||||||
const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`;
|
|
||||||
|
|
||||||
// Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts
|
// Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts
|
||||||
const parts: Array<TextPartInput | FilePartInput | AgentPartInputLite> = [];
|
const parts: Array<TextPartInput | FilePartInput | AgentPartInputLite> = [];
|
||||||
@@ -757,7 +795,7 @@ class OpencodeService {
|
|||||||
},
|
},
|
||||||
agent: params.agent,
|
agent: params.agent,
|
||||||
variant: params.variant,
|
variant: params.variant,
|
||||||
...(params.messageId ? { messageID: params.messageId } : {}),
|
messageID: messageId,
|
||||||
...(params.format ? { format: params.format } : {}),
|
...(params.format ? { format: params.format } : {}),
|
||||||
parts,
|
parts,
|
||||||
}),
|
}),
|
||||||
@@ -778,7 +816,7 @@ class OpencodeService {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
recordProviderSuccess(params.providerID);
|
recordProviderSuccess(params.providerID);
|
||||||
return tempMessageId;
|
return messageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRetry(params.providerID, response.status, attempt)) {
|
if (shouldRetry(params.providerID, response.status, attempt)) {
|
||||||
@@ -817,8 +855,7 @@ class OpencodeService {
|
|||||||
files?: Array<FileInputLite>;
|
files?: Array<FileInputLite>;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const baseTimestamp = Date.now();
|
const tempMessageId = params.messageId ?? ascendingId("msg");
|
||||||
const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`;
|
|
||||||
|
|
||||||
const parts: FilePartInput[] = [];
|
const parts: FilePartInput[] = [];
|
||||||
if (params.files && params.files.length > 0) {
|
if (params.files && params.files.length > 0) {
|
||||||
@@ -840,7 +877,7 @@ class OpencodeService {
|
|||||||
...(params.agent ? { agent: params.agent } : {}),
|
...(params.agent ? { agent: params.agent } : {}),
|
||||||
...(params.variant ? { variant: params.variant } : {}),
|
...(params.variant ? { variant: params.variant } : {}),
|
||||||
...(parts.length > 0 ? { parts } : {}),
|
...(parts.length > 0 ? { parts } : {}),
|
||||||
...(params.messageId ? { messageID: params.messageId } : {}),
|
messageID: tempMessageId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
const response = await fetch(url.toString(), {
|
||||||
|
|||||||
Reference in New Issue
Block a user