fix(ai-chat): prevent duplicate chat row on first-turn error; add adoption tests
Addresses the PR #138 review. Blocker 1 — duplicate chat row: a brand-new chat whose first turn errors BEFORE the SSE 'start' chunk never receives the authoritative chatId, so metadata adoption can't run; a retry then sent chatId:null and the server inserted a SECOND chat row, orphaning the first turn. Keep metadata adoption as the primary path (resolveAdoptedChatId) and add a bounded, unambiguous fallback: on a new-chat finish with no server id, snapshot the known chat ids and, once the list refetch lands, adopt the SINGLE newly-appeared id (pickNewlyCreatedChatId). Zero or >1 new ids (e.g. two tabs racing) → no adoption — no items[0] guessing, so #137 stays fixed. The wait-for-refetch guard compares set membership (robust to a concurrent delete), and the diff dedupes so a repeated id from a paginated list never reads as ambiguous. Blocker 2 — tests: new adopt-chat-id.test.ts covers both pure helpers (adopt decision + newly-created-id diff incl. dedupe/reorder); the server messageMetadata callback is extracted to chatStreamStartMetadata and unit-tested (start -> {chatId}, otherwise undefined). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
buildPartialAssistantRecord,
|
||||
chatStreamStartMetadata,
|
||||
MAX_AGENT_STEPS,
|
||||
FINAL_STEP_INSTRUCTION,
|
||||
} from './ai-chat.service';
|
||||
@@ -295,3 +296,20 @@ describe('buildPartialAssistantRecord', () => {
|
||||
expect(rec.text).toBe('half');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* chatStreamStartMetadata: attach the authoritative chatId to the streamed
|
||||
* assistant UI message ONLY on the `start` part (so the client adopts the real
|
||||
* created chat id at the first chunk — see #137). Any non-start part adds none.
|
||||
*/
|
||||
describe('chatStreamStartMetadata', () => {
|
||||
it('returns { chatId } for the start part', () => {
|
||||
expect(chatStreamStartMetadata({ type: 'start' }, 'chat-1')).toEqual({
|
||||
chatId: 'chat-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for a finish part (any non-start part)', () => {
|
||||
expect(chatStreamStartMetadata({ type: 'finish' }, 'chat-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,8 +512,7 @@ export class AiChatService {
|
||||
// very first chunk — before any second tab can race a newer chat into the
|
||||
// list. This fixes the two-tab "adoption race" (#137) where a new chat in
|
||||
// tab A could adopt tab B's id and leak its turns into the wrong row.
|
||||
messageMetadata: ({ part }) =>
|
||||
part.type === 'start' ? { chatId } : undefined,
|
||||
messageMetadata: ({ part }) => chatStreamStartMetadata(part, chatId),
|
||||
onError: (error: unknown) => {
|
||||
// Reuse the shared formatter so provider error formatting stays
|
||||
// unified between the log line and the streamed error message.
|
||||
@@ -562,6 +561,21 @@ export class AiChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `messageMetadata` payload for the streamed assistant UI message.
|
||||
* The AI SDK invokes `messageMetadata` on each stream part (ai@6); we attach the
|
||||
* authoritative `chatId` ONLY on the `start` part so it reaches the client at the
|
||||
* very first chunk (as `message.metadata.chatId`). The client adopts THAT id for a
|
||||
* new chat instead of guessing the newest chat in its list — fixing the two-tab
|
||||
* adoption race (#137). Returning undefined for any non-start part adds no metadata.
|
||||
*/
|
||||
export function chatStreamStartMetadata(
|
||||
part: { type: string },
|
||||
chatId: string,
|
||||
): { chatId: string } | undefined {
|
||||
return part.type === 'start' ? { chatId } : undefined;
|
||||
}
|
||||
|
||||
/** The last message with role 'user' from a useChat payload, if any. */
|
||||
function lastUserMessage(
|
||||
messages: UIMessage[] | undefined,
|
||||
|
||||
Reference in New Issue
Block a user