perf(ai-chat): throttle stream + memoize markdown to stop CPU spikes on long runs #182
Closed
vvzvlad
wants to merge 0 commits from
fix/ai-chat-stream-perf into develop
pull from: fix/ai-chat-stream-perf
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:test/244-part-b
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/184-autonomous-agent-runs
vvzvlad:feat/221-image-captions
vvzvlad:feat/git-sync
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/244-dataloss-bugs
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:develop
vvzvlad:feature/offline-sync
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
bug
documentation
duplicate
enhancement
epic
feature
good first issue
help wanted
idea
invalid
needs-human
question
refactor
review/approved
review/changes-requested
review/needs
security
status/blocked
status/done
status/in-progress
status/ready
test
wontfix
Something isn't working
Improvements or additions to documentation
This issue or pull request already exists
New feature or request
Large multi-phase effort spanning many changes
New functionality request
Good for newcomers
Extra attention is needed
Idea / proposal for discussion
This doesn't seem right
эскалация: нужно решение человека
Further information is requested
Code cleanup / refactoring
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Security / hardening issue
ждёт зависимость blocked_by
закрыто и проверено
в активной работе (мягкая заявка)
специфицировано, не заблокировано, ждёт исполнителя
Test coverage / test infrastructure
This will not be worked on
No Label
bug
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#182
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "fix/ai-chat-stream-perf"
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?
On long agent runs (dozens of tool calls) the desktop app froze at 100% CPU with no user interaction.
useChatupdated state on every streamed token, andMessageItem/ReasoningBlockre-parsed the whole transcript markdown (themarkedpipeline + DOMPurify) on every delta — per-turn work grew quadratically and saturated the main thread. A WebKit sample caught the regex engine inside the marked pipeline; the SSE stream drove it, so it hung "on its own".Fix (throttle + memoization)
experimental_throttle: 50touseChatso the streamedmessagesstate re-renders at most ~20 Hz instead of once per token.MessageItemon a cheap per-message content signature (the streaming tail still re-renders as it grows; finalized rows are skipped), and render each text part via a memoizedMarkdownPartso finalized parts are not re-parsed. The signature includesusage.reasoningTokensso the authoritative "Thinking · N tokens" count still snaps in at finish-step.useMemoon the text) and wrap the component inReact.memo.No marked extensions / regexes were changed (kept out of scope).
Verification
vitest run src/features/ai-chat— 177/177 pass.tsc --noEmit— no new errors in the touched files.🤖 Generated with Claude Code
Ревью отработано — все три блокера закрыты (push
63c26042)🔴 Staleness + потеря
collapseBlankLines. Влит актуальныйdevelop(merge-коммит2f058a6e), конфликт вreasoning-block.tsxразрешён с сохранением обоих поведений — мемоизации рендера иcollapseBlankLines:Merge также затронул
chat-thread.tsx(рефактор #174 сdevelop+experimental_throttleиз PR) — разрешён согласованно, без висячих ссылок на удалённыйliveStateRef. PR сноваmergeable.🔴 Тесты на
messageSignature. Хелпер вынесен вutils/message-signature.ts(по конвенции фичи: чистая функция надUIMessage+ колокейт-тест),message-item.tsxимпортирует его.utils/message-signature.test.tsпокрывает каждый сигнал изменения: рост текста, добавление part'а, сменаstate, появлениеoutput/errorText, прилётusage.reasoningTokensна finish-step (при замороженных text+state),metadata.error/finishReason— плюс негатив: идентичный по содержимому клон → равная сигнатура.🔴 Тесты на
arePropsEqual. Компаратор сделан экспортируемым;components/message-item.test.tsпокрывает все ветки: каждый проп-дифф →false, fast-path по идентичности →true, тот же контент в другом объекте →true, изменённый контент →false.🟢 Доп. Добавлен render-level тест
message-item-memo.test.tsx(через шпион наrenderChatMarkdownдоказывает, что финализированные text-part'ы не перепарсиваются, когда растёт только хвост), и запись вCHANGELOG.md→[Unreleased]→### Fixedпро зависание на 100% CPU (#182).Проверка:
vitest run src/features/ai-chat— 189/189 pass (16 новых тестов);tsc --noEmit— без ошибок в затронутых файлах.Пост-фикс ревью + защита инварианта (push
eafd15f0)Прогнал свежее независимое ревью поверх
63c26042. Блокирующих и значимых проблем не найдено — перф-фикс корректен для текущего приложения.Из трёх Minor-замечаний:
bindI18n) подписывает каждый компонент сuseTranslation()(MessageItem/ToolCallCard/ReasoningBlock) наlanguageChanged, и тот перерисовывается через локальный setState.React.memo/arePropsEqualгейтит только ререндеры от родителя, так что смена языка работает. Правка не нужна.outputset-once; текст append-only / длина как прокси контента) — недостижимы в текущем тулсете. Хэшироватьoutputв сигнатуре нельзя:getPageвозвращает полный контент страницы, и хэш на каждый дельта-токен нагрузил бы ровно тот горячий путь, который PR оптимизирует.Сделано (
eafd15f0): добавленWARNING-комментарий вmessageSignature, фиксирующий несущий инвариант мемоизации и два будущих триггера (стриминговыйpreliminaryoutput; клиентский regenerate/edit финализированной строки на месте) с требуемым действием — расширить сигнатуру. Изменение только комментарий, поведение не меняется.Проверка:
vitest run src/features/ai-chat— 189/189 pass;tsc --noEmit— без ошибок в затронутых файлах.Code review — PR #182: throttle стрима AI-чата + мемоизация рендера сообщений
Вердикт: Approve. Изменение когерентное и корректное: мемо-компаратор
arePropsEqualчерезmessageSignatureпокрывает все поля, которые сегодня рисуетMessageItem(text, reasoning, tool-* черезToolCallCard), сопровождён сильным load-bearing WARNING-комментарием, и вся новая логика покрыта тестами (message-signature.test.ts,message-item.test.ts,message-item-memo.test.tsx). Блокеров нет.Объём: дифф
develop…fix/ai-chat-stream-perf(merge-base3ddc329b), 8 файлов, +438/−30. Прогнаны параллельные аспектные ревьюеры (stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.Must fix before merge
Нет.
Non-blocking
metadataиз тестовой фабрикиmsg—apps/client/src/features/ai-chat/components/message-item.test.ts:19-29Фабрика
msgобъявляет параметрmetadata?: unknownи прокидывает его в возвращаемый объект, но ни один из 8 вызовов в файле его не передаёт (все —msg([{ type: "text", text: "answer" }])). Это мёртвый скаффолдинг, добавляющий шум и намекающий на несуществующее здесь покрытие по metadata (оно проверяется вmessage-signature.test.ts). Fix: убрать параметрmetadata?: unknownи полеmetadataиз фабрики, оставивconst msg = (parts: UIMessage["parts"]): UIMessage => ({ id: "m1", role: "assistant", parts }) as UIMessage;.Test coverage
Вся новая логика покрыта.
arePropsEqual/messageSignatureимеют выделенные тесты на смену видимых пропсов и на сигнатуру по типам частей (message-item.test.ts,message-signature.test.ts,message-item-memo.test.tsx); троттлинг стрима вchat-thread.tsxи правкиreasoning-block.tsxвходят в рамки этих же мемо-тестов.Architecture & design (forward-looking, non-blocking)
[architecture] Lockstep сигнатуры и рендера живёт в комментарии, а не в типах —
message-signature.ts+message-item.tsx,message-content.tsКорректность
MessageItemтеперь зависит от allowlist-сигнатуры (messageSignature) — кортежа на часть[type, text.length, state, errorText-presence, output-presence]плюс три поля metadata. Это уже третья функция, которая должна оставаться синхронной с решениями рендера (сам рендер-боди;assistantMessageHasVisibleContentвmessage-content.ts, уже задокументированная как «mirrors MessageItem's render decisions EXACTLY»; и теперьmessageSignature). Связь односторонняя и неявная: если будущий вид части начнёт рисовать поле, которое сигнатура не сэмплит (tool со стримингомpreliminaryoutput в одном state, in-place edit/regenerate финализированной строки, вывод toolinputв карточке), мемо молча заморозит устаревшую строку без сигнала на compile-/test-time. Дефекта в текущем дифф нет: все рисуемые сегодня виды частей и их видимые поля сигнатурой покрыты. Это maintenance-fragility, а не баг.message-signature.test.tsиmessage-item-memo.test.tsx, очень низкий риск. Cons: всё ещё per-part-kind и вручную — совсем новый вид части без добавленного теста остаётся незащищённым.Рекомендация: Опция B (дешёвый coupling-тест сейчас) или остаться на Опции A. Опция C не оправдана при текущем числе видов частей — стоит подождать, пока второй конкретный вид реально заденет инвариант.
Pull request closed