fix(ai-chat): assistant turn renders nothing — memo signature defeated by AI-SDK in-place part mutation (#182 regression) #224
Reference in New Issue
Block a user
Delete Branch "fix/ai-chat-empty-render"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Проблема (живая регрессия)
AI-чат с агентом перестал показывать ответ: рисуется только баббл юзера + «думающие» точки, а ответ ассистента (текст + карточки тул-коллов) НЕ появляется — хотя агент работает на сервере (выполняет тулы, оставляет комментарии). Введено мержем #182 (
99d0cb87perf: throttle + мемоизация рендера сообщений; сигнатура вынесена в63c26042).Корень (доказан эмпирически)
НЕ #202 (он только серверный — model-history
findRecent→findAllByChat; ассистент-сообщение приходит вuseChat.messagesполностью, SSE корректный — проверено фибер-дампом).Баг — в
apps/client/src/features/ai-chat/components/message-item.tsx,arePropsEqual. Он сравнивалmessageSignature(prev.message)vsmessageSignature(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 memoMarkdownPart(по примитиву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) — падают на старой реализации, проходят на новой.Проверка
tsc --noEmitexit 0.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
🤖 Generated with Claude Code