Merge pull request 'fix(ai-chat): adopt the server-returned chat id (two-tab adoption race #137)' (#138) from fix/ai-chat-chatid-adoption into develop

Reviewed-on: #138
This commit was merged in pull request #138.
This commit is contained in:
2026-06-23 03:35:03 +03:00
11 changed files with 825 additions and 119 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

@@ -532,6 +532,15 @@ export class AiChatService {
stripStreamingHopByHopHeaders(res.raw);
result.pipeUIMessageStreamToResponse(res.raw, {
headers: { 'X-Accel-Buffering': 'no' },
// Surface the authoritative chatId on the streamed assistant UI message so
// the client adopts the REAL id of the row we created, instead of guessing
// the newest chat in its list. `messageMetadata` is invoked by the AI SDK
// on the `start` and `finish` stream parts (ai@6); we attach `chatId` on the
// `start` part so it reaches the client (as message.metadata.chatId) at the
// 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 }) => 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.
@@ -582,6 +591,18 @@ export class AiChatService {
}
}
/**
* Attach the authoritative `chatId` to the streamed assistant message's `start`
* part (as `message.metadata.chatId`) so the client can adopt the real id for a
* new chat. See the client's adopt-chat-id.ts for the full #137 design.
*/
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,