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:
claude code agent 227
2026-06-23 01:17:30 +03:00
parent 0edc5aeda8
commit 580f3442b8
6 changed files with 196 additions and 22 deletions

View File

@@ -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();
});
});

View File

@@ -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,