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
Owner

On long agent runs (dozens of tool calls) the desktop app froze at 100% CPU with no user interaction. useChat updated state on every streamed token, and MessageItem/ReasoningBlock re-parsed the whole transcript markdown (the marked pipeline + 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)

  • chat-thread: pass experimental_throttle: 50 to useChat so the streamed messages state re-renders at most ~20 Hz instead of once per token.
  • message-item: memoize MessageItem on 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 memoized MarkdownPart so finalized parts are not re-parsed. The signature includes usage.reasoningTokens so the authoritative "Thinking · N tokens" count still snaps in at finish-step.
  • reasoning-block: memoize the markdown render (useMemo on the text) and wrap the component in React.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

On long agent runs (dozens of tool calls) the desktop app froze at 100% CPU with no user interaction. `useChat` updated state on every streamed token, and `MessageItem`/`ReasoningBlock` re-parsed the **whole transcript** markdown (the `marked` pipeline + 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) - **chat-thread**: pass `experimental_throttle: 50` to `useChat` so the streamed `messages` state re-renders at most ~20 Hz instead of once per token. - **message-item**: memoize `MessageItem` on 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 memoized `MarkdownPart` so finalized parts are not re-parsed. The signature includes `usage.reasoningTokens` so the authoritative "Thinking · N tokens" count still snaps in at finish-step. - **reasoning-block**: memoize the markdown render (`useMemo` on the text) and wrap the component in `React.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](https://claude.com/claude-code)
vvzvlad added 1 commit 2026-06-25 03:27:25 +03:00
On long agent runs (dozens of tool calls) the desktop app froze at 100% CPU with
no user interaction: useChat updated state on every streamed token, and
MessageItem/ReasoningBlock re-parsed the whole transcript's markdown (the marked
pipeline + DOMPurify) on every delta. Per-turn work grew quadratically and
saturated the main thread; the SSE stream drove it, so it hung "on its own".

- chat-thread: pass experimental_throttle (50ms) to useChat so the streamed
  messages state re-renders at most ~20 Hz instead of once per token.
- message-item: memoize MessageItem on 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 memoized MarkdownPart so finalized parts are
  not re-parsed. The signature includes usage.reasoningTokens so the
  authoritative "Thinking - N tokens" count still snaps in at finish-step.
- reasoning-block: memoize the markdown render (useMemo on the text) and wrap the
  component in React.memo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added the bug label 2026-06-25 22:16:23 +03:00
vvzvlad added 2 commits 2026-06-25 22:33:47 +03:00
# Conflicts:
#	apps/client/src/features/ai-chat/components/reasoning-block.tsx
Resolve the PR #182 code-review (Request changes) on top of the already-merged
develop (the merge commit preserves both the markdown useMemo and the
collapseBlankLines fix in reasoning-block.tsx).

- Extract messageSignature from message-item.tsx into utils/message-signature.ts
  (matches the feature's "pure UIMessage helper + colocated test" convention) and
  export arePropsEqual so the memo seam is unit-testable. No logic change.
- Add utils/message-signature.test.ts covering every change signal (text grows,
  part appended, state flip, output appears, errorText appears, usage.reasoningTokens
  arriving on finish-step, metadata error/finishReason) plus the negative
  content-identical-clone case.
- Add components/message-item.test.ts for arePropsEqual (each prop diff -> false,
  identity fast-path -> true, same-content-different-object -> true, changed -> false).
- Add components/message-item-memo.test.tsx: render-level proof that finalized text
  parts are not re-parsed when only a tail part grows (MarkdownPart memo).
- CHANGELOG: add the user-facing 100% CPU freeze fix under [Unreleased] / Fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Ревью отработано — все три блокера закрыты (push 63c26042)

🔴 Staleness + потеря collapseBlankLines. Влит актуальный develop (merge-коммит 2f058a6e), конфликт в reasoning-block.tsx разрешён с сохранением обоих поведений — мемоизации рендера и collapseBlankLines:

const html = useMemo(
  () => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
  [trimmed],
);

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-chat189/189 pass (16 новых тестов); tsc --noEmit — без ошибок в затронутых файлах.

## Ревью отработано — все три блокера закрыты (push `63c26042`) **🔴 Staleness + потеря `collapseBlankLines`.** Влит актуальный `develop` (merge-коммит `2f058a6e`), конфликт в `reasoning-block.tsx` разрешён с сохранением **обоих** поведений — мемоизации рендера и `collapseBlankLines`: ```ts const html = useMemo( () => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""), [trimmed], ); ``` 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` — без ошибок в затронутых файлах.
vvzvlad added 1 commit 2026-06-25 23:47:34 +03:00
PR #182 review (post-fix pass) surfaced two latent correctness risks in the
new MessageItem memo: the per-message signature tracks only [type, text length,
state, error/output presence] + metadata, so a part kind whose VISIBLE content
can change WITHOUT changing those fields would silently freeze a stale row.
Neither is reachable with the current toolset (tool output is set once;
streaming is append-only with a fixed id), so the correct fix is to harden the
documented invariant rather than hash output content on every delta (getPage
returns full page content — hashing it per-delta would tax the hot path this
PR optimizes).

Add a WARNING in messageSignature naming the two future triggers (a tool that
streams `preliminary` output; a client-side regenerate/edit that mutates a
finalized row in place) and the required action (extend the signature).

No behavior change (comment only). vitest src/features/ai-chat 189/189 pass,
tsc clean for the touched files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Пост-фикс ревью + защита инварианта (push eafd15f0)

Прогнал свежее независимое ревью поверх 63c26042. Блокирующих и значимых проблем не найдено — перф-фикс корректен для текущего приложения.

Из трёх Minor-замечаний:

  • 🟢 «Смена языка не перерисует финализированные строки» — ложная тревога. react-i18next (дефолтный bindI18n) подписывает каждый компонент с useTranslation() (MessageItem/ToolCallCard/ReasoningBlock) на languageChanged, и тот перерисовывается через локальный setState. React.memo/arePropsEqual гейтит только ререндеры от родителя, так что смена языка работает. Правка не нужна.
  • 🟡 Два латентных риска инварианта (output set-once; текст append-only / длина как прокси контента) — недостижимы в текущем тулсете. Хэшировать output в сигнатуре нельзя: getPage возвращает полный контент страницы, и хэш на каждый дельта-токен нагрузил бы ровно тот горячий путь, который PR оптимизирует.

Сделано (eafd15f0): добавлен WARNING-комментарий в messageSignature, фиксирующий несущий инвариант мемоизации и два будущих триггера (стриминговый preliminary output; клиентский regenerate/edit финализированной строки на месте) с требуемым действием — расширить сигнатуру. Изменение только комментарий, поведение не меняется.

Проверка: vitest run src/features/ai-chat189/189 pass; tsc --noEmit — без ошибок в затронутых файлах.

## Пост-фикс ревью + защита инварианта (push `eafd15f0`) Прогнал свежее независимое ревью поверх `63c26042`. **Блокирующих и значимых проблем не найдено** — перф-фикс корректен для текущего приложения. Из трёх Minor-замечаний: - 🟢 **«Смена языка не перерисует финализированные строки» — ложная тревога.** react-i18next (дефолтный `bindI18n`) подписывает каждый компонент с `useTranslation()` (`MessageItem`/`ToolCallCard`/`ReasoningBlock`) на `languageChanged`, и тот перерисовывается через **локальный** setState. `React.memo`/`arePropsEqual` гейтит только ререндеры **от родителя**, так что смена языка работает. Правка не нужна. - 🟡 **Два латентных риска инварианта** (`output` set-once; текст append-only / длина как прокси контента) — недостижимы в текущем тулсете. Хэшировать `output` в сигнатуре нельзя: `getPage` возвращает полный контент страницы, и хэш на каждый дельта-токен нагрузил бы ровно тот горячий путь, который PR оптимизирует. **Сделано (`eafd15f0`):** добавлен `WARNING`-комментарий в `messageSignature`, фиксирующий несущий инвариант мемоизации и два будущих триггера (стриминговый `preliminary` output; клиентский regenerate/edit финализированной строки на месте) с требуемым действием — расширить сигнатуру. Изменение только комментарий, поведение не меняется. **Проверка:** `vitest run src/features/ai-chat` — **189/189 pass**; `tsc --noEmit` — без ошибок в затронутых файлах.
Author
Owner

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). Блокеров нет.

Объём: дифф developfix/ai-chat-stream-perf (merge-base 3ddc329b), 8 файлов, +438/−30. Прогнаны параллельные аспектные ревьюеры (stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.

Must fix before merge

Нет.

Non-blocking

  • [simplification] Убрать неиспользуемый параметр metadata из тестовой фабрики msgapps/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 со стримингом preliminary output в одном state, in-place edit/regenerate финализированной строки, вывод tool input в карточке), мемо молча заморозит устаревшую строку без сигнала на compile-/test-time. Дефекта в текущем дифф нет: все рисуемые сегодня виды частей и их видимые поля сигнатурой покрыты. Это maintenance-fragility, а не баг.

    • Опция A — оставить как есть (util сигнатуры + WARNING-комментарий) (effort: s). Pros: ноль работы; комментарий явно называет режим отказа и два конкретных будущих триггера; текущее покрытие доказуемо полное. Cons: инвариант остаётся «задокументирован комментарием» — новый вид части может молча регрессировать без падения теста.
    • Опция B — добавить регресс-тест на coupling сигнатура↔рендер по каждому виду части (effort: s). Pros: превращает комментарный инвариант в исполняемый guard для существующих сегодня видов, дополняет message-signature.test.ts и message-item-memo.test.tsx, очень низкий риск. Cons: всё ещё per-part-kind и вручную — совсем новый вид части без добавленного теста остаётся незащищённым.
    • Опция C — выводить сигнатуру из единого per-part «render descriptor», чтобы рендер и сигнатура имели один источник истины (effort: m). Pros: делает рендер и мемо доказуемо согласованными — каждый вид части один раз декларирует видимые поля, и рендер-боди, и сигнатура их потребляют; устраняет трёхстороннний lockstep. Cons: вводит слой абстракции над пока небольшим набором видов частей, риск over-engineering, диф крупнее, чем оправдывает текущая правка.

    Рекомендация: Опция B (дешёвый coupling-тест сейчас) или остаться на Опции A. Опция C не оправдана при текущем числе видов частей — стоит подождать, пока второй конкретный вид реально заденет инвариант.

## 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-base `3ddc329b`), 8 файлов, +438/−30. Прогнаны параллельные аспектные ревьюеры (stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход._ ### Must fix before merge Нет. ### Non-blocking - **[simplification] Убрать неиспользуемый параметр `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 со стримингом `preliminary` output в одном state, in-place edit/regenerate финализированной строки, вывод tool `input` в карточке), мемо молча заморозит устаревшую строку без сигнала на compile-/test-time. Дефекта в текущем дифф нет: все рисуемые сегодня виды частей и их видимые поля сигнатурой покрыты. Это maintenance-fragility, а не баг. - **Опция A — оставить как есть (util сигнатуры + WARNING-комментарий)** (effort: s). Pros: ноль работы; комментарий явно называет режим отказа и два конкретных будущих триггера; текущее покрытие доказуемо полное. Cons: инвариант остаётся «задокументирован комментарием» — новый вид части может молча регрессировать без падения теста. - **Опция B — добавить регресс-тест на coupling сигнатура↔рендер по каждому виду части** (effort: s). Pros: превращает комментарный инвариант в исполняемый guard для существующих сегодня видов, дополняет `message-signature.test.ts` и `message-item-memo.test.tsx`, очень низкий риск. Cons: всё ещё per-part-kind и вручную — совсем новый вид части без добавленного теста остаётся незащищённым. - **Опция C — выводить сигнатуру из единого per-part «render descriptor», чтобы рендер и сигнатура имели один источник истины** (effort: m). Pros: делает рендер и мемо доказуемо согласованными — каждый вид части один раз декларирует видимые поля, и рендер-боди, и сигнатура их потребляют; устраняет трёхстороннний lockstep. Cons: вводит слой абстракции над пока небольшим набором видов частей, риск over-engineering, диф крупнее, чем оправдывает текущая правка. Рекомендация: Опция B (дешёвый coupling-тест сейчас) или остаться на Опции A. Опция C не оправдана при текущем числе видов частей — стоит подождать, пока второй конкретный вид реально заденет инвариант.
Ghost added 1 commit 2026-06-26 16:57:48 +03:00
Address non-blocking review items on the AI-chat stream-perf PR:

- Drop the unused `metadata` param from the `msg` test factory in
  message-item.test.ts; no caller passed it.
- Add a per-part-kind coupling guard to message-signature.test.ts that, for
  each part kind rendered today (text, reasoning, tool-*) plus the metadata
  banners, asserts that mutating a field the MessageItem render body DRAWS
  flips messageSignature — an executable lock for the load-bearing memo
  invariant documented in message-signature.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost closed this pull request 2026-06-26 17:10:31 +03:00

Pull request closed

Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#182