fix(ai-chat): assistant turn renders nothing — memo signature defeated by AI-SDK in-place part mutation (#182 regression) #224

Merged
vvzvlad merged 1 commits from fix/ai-chat-empty-render into develop 2026-06-26 22:09:06 +03:00

Проблема (живая регрессия)

AI-чат с агентом перестал показывать ответ: рисуется только баббл юзера + «думающие» точки, а ответ ассистента (текст + карточки тул-коллов) НЕ появляется — хотя агент работает на сервере (выполняет тулы, оставляет комментарии). Введено мержем #182 (99d0cb87 perf: throttle + мемоизация рендера сообщений; сигнатура вынесена в 63c26042).

Корень (доказан эмпирически)

НЕ #202 (он только серверный — model-history findRecentfindAllByChat; ассистент-сообщение приходит в useChat.messages полностью, SSE корректный — проверено фибер-дампом).

Баг — в apps/client/src/features/ai-chat/components/message-item.tsx, arePropsEqual. Он сравнивал messageSignature(prev.message) vs messageSignature(next.message). Но AI SDK (ai@6 / @ai-sdk/react@3) стримит turn, мутируя один и тот же массив parts на месте, и возвращает обёртку сообщения, разделяющую эти мутированные parts — поэтому ВНУТРИ компаратора обе стороны уже отражают последний контент, и сигнатуры всегда равны. Memo пропускал каждый пост-маунт ререндер → строка ассистента застывала на初ачальном пустом (null) рендере. Reasoning-first провайдеры (z.ai/GLM) начинают turn с невидимого reasoning-парта → не появлялось вообще ничего.

Доказательство: за один turn arePropsEqual вызван 86 раз — все 86 вернули равенство, хотя логируемая сигнатура росла (reasoning:83274…), т.к. prev/next делят мутируемые parts. Отключение memo → ассистент рендерится.

Фикс

Снимать сигнатуру в родителе (MessageList) на момент рендера и передавать в MessageItem иммутабельным строковым пропом signature; arePropsEqual сравнивает этот проп (захваченная строка иммутабельна → prev.signature хранит контент предыдущего рендера). Убран некорректный фаст-пас prev.message === next.message. Per-part memo MarkdownPart (по примитиву text) не затронут. Файлы: message-item.tsx, message-list.tsx.

Почему тесты #182 это не поймали

Они строили НОВЫЕ объекты сообщений на каждый рендер (сигнатуры различались), а реальный AI SDK мутирует на месте (prev/next делят parts). Добавлены 2 регресс-теста, симулирующие in-place мутацию (render-тест в message-item-memo.test.tsx + компаратор-тест в message-item.test.ts) — падают на старой реализации, проходят на новой.

Проверка

  • Реальный провайдер (z.ai, glm-5.2), браузер → /api/ai-chat/stream: turn рендерится end-to-end (имя агента, reasoning «Thinking · N tokens», стримящийся markdown, карточка тул-колла).
  • ai-chat сьют 202 passed; client+server tsc --noEmit exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

🤖 Generated with Claude Code

## Проблема (живая регрессия) AI-чат с агентом перестал показывать ответ: рисуется только баббл юзера + «думающие» точки, а ответ ассистента (текст + карточки тул-коллов) НЕ появляется — хотя агент работает на сервере (выполняет тулы, оставляет комментарии). Введено мержем #182 (`99d0cb87` perf: throttle + мемоизация рендера сообщений; сигнатура вынесена в `63c26042`). ## Корень (доказан эмпирически) **НЕ #202** (он только серверный — model-history `findRecent`→`findAllByChat`; ассистент-сообщение приходит в `useChat.messages` полностью, SSE корректный — проверено фибер-дампом). Баг — в `apps/client/src/features/ai-chat/components/message-item.tsx`, `arePropsEqual`. Он сравнивал `messageSignature(prev.message)` vs `messageSignature(next.message)`. Но AI SDK (`ai@6` / `@ai-sdk/react@3`) стримит turn, **мутируя один и тот же массив `parts` на месте**, и возвращает обёртку сообщения, **разделяющую** эти мутированные parts — поэтому ВНУТРИ компаратора обе стороны уже отражают последний контент, и сигнатуры **всегда равны**. Memo пропускал каждый пост-маунт ререндер → строка ассистента застывала на初ачальном пустом (null) рендере. Reasoning-first провайдеры (z.ai/GLM) начинают turn с невидимого reasoning-парта → не появлялось вообще ничего. Доказательство: за один turn `arePropsEqual` вызван **86 раз — все 86 вернули равенство**, хотя логируемая сигнатура росла (`reasoning:8`→`32`→`74`…), т.к. prev/next делят мутируемые parts. Отключение memo → ассистент рендерится. ## Фикс Снимать сигнатуру в родителе (`MessageList`) на момент рендера и передавать в `MessageItem` иммутабельным строковым пропом `signature`; `arePropsEqual` сравнивает этот проп (захваченная строка иммутабельна → `prev.signature` хранит контент предыдущего рендера). Убран некорректный фаст-пас `prev.message === next.message`. Per-part memo `MarkdownPart` (по примитиву `text`) не затронут. Файлы: `message-item.tsx`, `message-list.tsx`. ## Почему тесты #182 это не поймали Они строили НОВЫЕ объекты сообщений на каждый рендер (сигнатуры различались), а реальный AI SDK мутирует на месте (prev/next делят parts). Добавлены 2 регресс-теста, симулирующие in-place мутацию (render-тест в `message-item-memo.test.tsx` + компаратор-тест в `message-item.test.ts`) — падают на старой реализации, проходят на новой. ## Проверка - **Реальный провайдер (z.ai, glm-5.2), браузер → /api/ai-chat/stream:** turn рендерится end-to-end (имя агента, reasoning «Thinking · N tokens», стримящийся markdown, карточка тул-колла). - ai-chat сьют **202 passed**; client+server `tsc --noEmit` exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-26 22:04:43 +03:00
The floating AI chat rendered NOTHING for the assistant turn (user bubble +
"thinking" dots showed, but the streamed text and tool-call cards never
appeared) even though the agent ran server-side. The parts DID arrive in
`useChat.messages` — this was purely a render freeze.

Root cause: the MessageItem `React.memo` comparator (#182) decided whether to
re-render by recomputing `messageSignature(prev.message)` vs
`messageSignature(next.message)` inside `arePropsEqual` (plus a
`prev.message === next.message` fast path). But the AI SDK (ai@6 /
@ai-sdk/react@3) streams a turn by MUTATING the same `parts` in place and
handing back a message wrapper that SHARES those mutated parts. So inside the
comparator both `prev.message` and `next.message` already reflect the latest
content — the two signatures are ALWAYS equal — and the memo skipped every
post-mount render. The assistant row therefore froze at its initial empty
(null) render; reasoning-first providers (e.g. z.ai/GLM) start with a
non-visible reasoning part, so the whole answer + tool cards never showed.

Fix: snapshot the signature in the PARENT (MessageList) at render time and pass
it to MessageItem as an immutable `signature` string prop; `arePropsEqual` now
compares that prop. A captured string is immutable, so `prev.signature` holds
the previous render's content and `next.signature` the new content — they differ
as the turn streams in and the row re-renders. Drop the now-incorrect
`prev.message === next.message` fast path (same-ref-but-mutated must still
re-render). MarkdownPart's per-part memo is unaffected (it already keys on the
primitive `text`).

Verified end-to-end against a real OpenAI-compatible provider: the assistant
turn (reasoning + streamed text + tool-call card) now renders live and on
finish. Regression tests added (render + comparator) that fail before / pass
after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad merged commit 08c70cf550 into develop 2026-06-26 22:09:06 +03:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#224