[bug][ai-chat] New chat adopts the wrong chat id under a two-tab race → turns leak into another chat #137

Closed
opened 2026-06-22 21:29:39 +03:00 by Ghost · 0 comments

Severity: high (data integrity — turns persist into the wrong chat; the agent reads another chat's history)

Symptom

With two AI-chat tabs open, a message/command typed in one chat "leaks" into another chat: subsequent turns of tab A are saved into tab B's chat, and tab A's agent answers using tab B's history — while tab A's visible UI still shows its own conversation. ("Copy chat"/export and the agent see the wrong chat; the live UI shows the right one.)

Root cause (confirmed in code + reproduced live)

When a user starts a new chat (activeChatId === null) and sends the first message, the server creates the ai_chats row (ai-chat.service.ts, new-chat insert) but the SSE stream never returns the new chat id to the client (no messageMetadata/data part/header carries it). The client therefore "adopts" the id by guessing the newest chat in the per-user list:

  • ai-chat-window.tsxonTurnFinished arms adoptNewChat, then the adopt effect takes chats?.items?.[0] as the id to bind the thread to.
  • useAiChatsQuery is per-user, not per-tab; the server orders ai_chats.created_at desc (ai-chat.repo.ts), so items[0] is whichever chat was created most recently across all the user's tabs.

So if a second tab creates a new chat at ~the same time (its row is newer), the first tab adopts the other tab's chat id. From then on tab A's chatId points at tab B's chat: A's turns persist there and the agent rebuilds history from there.

Reproduction (live, two browser contexts, same user)

  1. Tab A: New chat → "My secret codeword is FIG. Reply with only: OK".
  2. While A is still streaming its first turn (~1s later), Tab B: New chat → "My secret codeword is CIRCUS. Reply with only: OK". (B's row is created after A's → B is items[0].)
  3. After both turns finish, Tab A: "What is my secret codeword?".
  4. Tab A's agent answers "CIRCUS" (B's word), though A's UI still shows the FIG conversation.

DB proof

chat A 019ef094-9d17  "Setting up a secret codeword"  created 18:25:31.924
  user      My secret codeword is FIG...
  assistant ...
chat B 019ef094-a454  "Secret codeword is Circus"     created 18:25:33.779
  user      My secret codeword is CIRCUS...
  assistant OK
  user      What is my secret codeword?         <-- tab A's 2nd turn
  assistant CIRCUS                               <-- leaked into chat B

A's chat row contains only A's first turn; A's second turn landed in B's chat row. The race window is the duration of tab A's first turn, so it triggers easily on a normal multi-tab workflow.

Suggested fix

Return the authoritative new chat id from the server and adopt that, instead of guessing items[0]:

  • Server: emit the created chatId on the stream for a new chat — e.g. via the AI SDK messageMetadata/a data part, or an X-Chat-Id response header on /ai-chat/stream.
  • Client: in the transport/onResponse (or onFinish), read that id and setActiveChatId(returnedId) / bind the thread to it; drop the chats.items[0] heuristic. This removes the per-user race entirely.

Note (separate, environment)

On the latest develop, AI chat returns HTTP 500 on every /api/ai-chat/chats and /api/ai-chat/stream until migration 20260622T120000-ai-chat-page-origin (adds ai_chats.page_id) is applied — ai-chat.repo.ts findByCreator selects page_id/joins pages. Not a code bug, but it fully blocks AI chat on an un-migrated DB.

Not reproduced (reported separately by the user, checked here)

"History partially missing on reload, appears on next reload" and "history wrong when opening a saved chat in another tab" did not reproduce in isolation on this stand (full history rendered on every reload and in a fresh context). However, the adoption leak above splits one logical conversation across two chat rows, which can present as "part of the history is missing / it's in the other chat".


Reproduced live on develop @ v0.93.0-99-g2d7f85fc with z.ai (glm-5.2) streaming; DB-correlated.

**Severity:** high (data integrity — turns persist into the wrong chat; the agent reads another chat's history) ### Symptom With two AI-chat tabs open, a message/command typed in one chat "leaks" into another chat: subsequent turns of tab A are saved into tab B's chat, and tab A's agent answers using tab B's history — while tab A's visible UI still shows its own conversation. ("Copy chat"/export and the agent see the wrong chat; the live UI shows the right one.) ### Root cause (confirmed in code + reproduced live) When a user starts a **new** chat (`activeChatId === null`) and sends the first message, the server creates the `ai_chats` row (`ai-chat.service.ts`, new-chat insert) but the **SSE stream never returns the new chat id to the client** (no `messageMetadata`/data part/header carries it). The client therefore "adopts" the id by guessing the **newest chat in the per-user list**: - `ai-chat-window.tsx` — `onTurnFinished` arms `adoptNewChat`, then the adopt effect takes `chats?.items?.[0]` as the id to bind the thread to. - `useAiChatsQuery` is per-**user**, not per-tab; the server orders `ai_chats.created_at desc` (`ai-chat.repo.ts`), so `items[0]` is whichever chat was created most recently **across all the user's tabs**. So if a second tab creates a new chat at ~the same time (its row is newer), the first tab adopts the **other tab's** chat id. From then on tab A's `chatId` points at tab B's chat: A's turns persist there and the agent rebuilds history from there. ### Reproduction (live, two browser contexts, same user) 1. Tab A: New chat → "My secret codeword is FIG. Reply with only: OK". 2. While A is still streaming its first turn (~1s later), Tab B: New chat → "My secret codeword is CIRCUS. Reply with only: OK". (B's row is created after A's → B is `items[0]`.) 3. After both turns finish, Tab A: "What is my secret codeword?". 4. **Tab A's agent answers "CIRCUS"** (B's word), though A's UI still shows the FIG conversation. ### DB proof ``` chat A 019ef094-9d17 "Setting up a secret codeword" created 18:25:31.924 user My secret codeword is FIG... assistant ... chat B 019ef094-a454 "Secret codeword is Circus" created 18:25:33.779 user My secret codeword is CIRCUS... assistant OK user What is my secret codeword? <-- tab A's 2nd turn assistant CIRCUS <-- leaked into chat B ``` A's chat row contains only A's first turn; A's second turn landed in **B's** chat row. The race window is the duration of tab A's first turn, so it triggers easily on a normal multi-tab workflow. ### Suggested fix Return the authoritative new chat id from the server and adopt **that**, instead of guessing `items[0]`: - Server: emit the created `chatId` on the stream for a new chat — e.g. via the AI SDK `messageMetadata`/a data part, or an `X-Chat-Id` response header on `/ai-chat/stream`. - Client: in the transport/`onResponse` (or `onFinish`), read that id and `setActiveChatId(returnedId)` / bind the thread to it; drop the `chats.items[0]` heuristic. This removes the per-user race entirely. ### Note (separate, environment) On the latest `develop`, AI chat returns **HTTP 500 on every `/api/ai-chat/chats` and `/api/ai-chat/stream`** until migration `20260622T120000-ai-chat-page-origin` (adds `ai_chats.page_id`) is applied — `ai-chat.repo.ts findByCreator` selects `page_id`/joins `pages`. Not a code bug, but it fully blocks AI chat on an un-migrated DB. ### Not reproduced (reported separately by the user, checked here) "History partially missing on reload, appears on next reload" and "history wrong when opening a saved chat in another tab" did **not** reproduce in isolation on this stand (full history rendered on every reload and in a fresh context). However, the adoption leak above splits one logical conversation across two chat rows, which can present as "part of the history is missing / it's in the other chat". --- _Reproduced live on `develop` @ `v0.93.0-99-g2d7f85fc` with z.ai (glm-5.2) streaming; DB-correlated._
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#137