fix(ai-chat): reset chat on New chat during the first turn's stream (#161) #162
Closed
Ghost
wants to merge 3 commits from
fix/ai-chat-new-chat-during-stream into develop
pull from: fix/ai-chat-new-chat-during-stream
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-stream-perf
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#162
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-new-chat-during-stream"
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?
Closes #161.
Проблема
«New chat» во время стрима первого хода ещё не усыновлённого нового чата лишь убирал бейдж роли — сам чат/сессия/стрим оставались. Перемонтирование треда управляется render-фазовым реконсилером в
useChatSession, который срабатывает только приactiveChatId !== thread.chatId. На новом (ещё не усыновлённом) чате обаnull, поэтомуsetActiveChatId(null)вstartNewChat— no-op, и remount не происходит.Решение
startFreshThread()вuseChatSession— безусловно диспатчитreconcileна свежий mount-key, так чтоChatThreadперемонтируется даже когдаactiveChatIdостаётсяnull.startNewChatтеперь его вызывает. После диспатчаthread.chatId === null === activeChatId, поэтому реконсилер не делает второй remount.useChat(ai@6) не прерывает запрос при unmount и проксирует колбэки, поэтомуonFinish/onErrorпокинутого треда срабатывают позже и черезonTurnFinishedусыновили бы только что покинутый чат, выдёргивая пользователя обратно. ТеперьonTurnFinished(serverChatId?, finishingThreadKey?): если ключ завершившегося треда не совпадает с текущим смонтированным (threadKeyRef), делается толькоonInvalidateChatList()— без adoption, без арма fallback, без per-chat инвалидации сообщений.ChatThreadпрокидывает свойthreadKeyв обоих колбэках. Отсутствующий ключ (старые вызовы/тесты) трактуется как текущий тред — поведение #137 сохранено.Тесты
3 новых кейса в
use-chat-session.test.tsx: remount приactiveChatId===null; поздний finish брошенного треда не усыновляет и не инвалидирует сообщения (но обновляет список); finish текущего треда (совпадающий ключ) усыновляет. Весь набор ai-chat зелёный (183),tscчисто.Границы
Как и в исходном дизайне issue, правка сознательно не прерывает фоновый стрим брошенного хода на сервере (консистентно с поведением
selectChat). Реальный abort при unmount — отдельная задача.🤖 Generated with Claude Code
Code review — PR #162: reset chat on «New chat» during the first turn's stream (#161)
Вердикт: Request changes — сама реализация корректна и хорошо покрыта тестами (6 из 7 аспектов LGTM), но PR не вливается в текущий
developи требует ребейза; при ребейзе нужно перепроверить стык с #174. Это единственный блокер; логика фикса — approve-уровня.Must fix before merge
[integration] Сделать rebase ветки на текущий
developи переразрешить конфликты. Файлыai-chat-window.tsx,chat-thread.tsx,hooks/use-chat-session.ts,hooks/use-chat-session.test.tsxизменены и в #174 (уже вdevelop), и в этом PR — поэтому PR сейчас не мержится. При ребейзе:useChatSessionиonServerChatId(из #174), и новыйstartFreshThread;threadKeyRef/finishingThreadKey) корректно сочетается с ранней адопцией #174.adoptсохраняет тот же mount-key, поэтомуthreadKeyRef.current === thread.keyостаётся валидным после ранней адопции, аstartFreshThreadпо-прежнему нужен для окна до чанкаstart(покаactiveChatId === null). Дополнительный guard наonServerChatIdне требуется: он вызывается изuseEffect([messages]), а эффекты размонтированного треда не срабатывают — late-fire ему не грозит (в отличие отonFinish/onError, которыеuseChatпроксирует и после unmount).[suggestion][simplification] Убрать ставший избыточным
cancelPendingAdoption()вstartNewChat—ai-chat-window.tsx. Идущий сразу следомstartFreshThread()уже выполняетpendingNewChatRef.current = null, и между двумя вызовами ничего от disarm не зависит. Сам экспортcancelPendingAdoptionоставить — он по-прежнему нужен вselectChat. На корректность не влияет; можно оставить ради симметрииstartNewChat/selectChat.Test coverage
Все новые ветки покрыты осмысленными ассертами (прогон vitest из клиентского конфига: 13/13 зелёные):
startFreshThread()— remount приactiveChatId === null(mount-key меняется);onTurnFinished— нет adopt, нет per-chat инвалидации, но список инвалидируется;Ветка
finishingThreadKey === undefined(legacy/тесты) покрыта существующими тестами без отдельного именованного кейса — достаточно. Компонентных тестов наai-chat-window/chat-threadнет, но их в проекте и не было: хук — установленный unit-seam, это не пробел. Регресс-замок на #161 присутствует (тесты падали бы на до-фиксовом коде).Architecture & design (forward-looking, non-blocking)
Консолидировать разрозненные guard'ы «этот late-callback — от смонтированного треда?» в один seam. Модуль накапливает ad-hoc защиты от late-callback'ов размонтированных тредов:
pendingNewChatRef+cancelPendingAdoption, зеркалоactiveChatIdRef, и теперьthreadKeyRef+ параметрfinishingThreadKey. Корень один —useChat(ai@6) не abort'ит запрос на unmount и проксируетonFinish/onError.один предикат
isCurrentThread(key), через который проходят все мутирующие identity-колбэки.* Плюсы: корень назван один раз, новые колбэки получают unmount-safety бесплатно, меньше концептуальная поверхность самого баговатого места модуля. Минусы: рефактор со своими тестами; делать после ребейза. Усилия: medium.Aspects: stability ✅, regressions ✅, conventions ✅, documentation ✅, test-coverage ✅, simplification (1 nit), architecture (1 proposal). Security — N/A.
Обновлено: блокер ревью устранён, ветка влита с актуальным
developЗапушен merge-коммит
4d695180(cee231b1..4d695180).developтеперь предок ветки — PR вливается без конфликтов.Что сделано
developс разрешением конфликтов вai-chat-window.tsx,chat-thread.tsx,use-chat-session.ts(конфликт был с уже влитыми #174 и #183).onServerChatId(#174) во всех трёх файлах + ранняя адопция chatId.liveStateRef/liveThreadRef/MutableRefObject: он жил в merge-base и был лишь унаследован этой веткой, к #161 отношения не имеет. За базу взята версияdevelop, поверх привит только реальный вклад #162 (startFreshThread, guard брошенного тредаthreadKeyRef+finishingThreadKey, пробросthreadKeyизChatThread).cancelPendingAdoption()вstartNewChat(его роль выполняетstartFreshThread(), который обнуляетpendingNewChatRef); экспортcancelPendingAdoptionсохранён — он по-прежнему нужен вselectChat.Проверка
git diff developпо 4 файлам = ровно вклад #162 + упрощение, ничего лишнего.#161:).Архитектурное предложение из ревью (консолидация guard'ов late-callback в один
isCurrentThread(key)seam) осознанно оставлено на отдельную задачу — оно non-blocking.Код-ревью — PR #162: сброс чата по «New chat» во время стрима первого хода (#161)
Вердикт: Request changes (запросить изменения). Сам код корректен — все «тяжёлые» аспекты (стабильность, регрессии, безопасность, конвенции, упрощение) дали LGTM, ключевая логика проверена на интерливингах (см. ниже). Слияние блокируют только два мелких пункта дисциплины: нет записи в
CHANGELOGи устаревший заголовок/комментарий ранее существовавшего теста, который после правки описывает уже несуществующее поведениеstartNewChat.Объём ревью: диф
develop..4d695180по 4 файлам (+127/−13). Ветка слита с актуальнымdevelop, конфликтов нет. Запущено 8 аспектных ревьюеров параллельно; находки сведены и перепроверены по исходникам.Must fix before merge
[documentation] Добавить запись в
CHANGELOG[Unreleased] › Fixedпро #161 —CHANGELOG.md(секция[Unreleased]). Фикс пользовательский: нажатие «New chat» во время стрима первого хода ещё не усыновлённого чата оставляло живой стримящийся тред, а поздний refetch / позднийonFinishмог выдернуть пользователя обратно в покинутый чат. По принятой в проекте конвенции такие фиксы фиксируются в### Fixedс номером ишью (рядом в файле —(#163),(#146, #147));#161вCHANGELOG.mdотсутствует. Fix: добавить буллет под## [Unreleased] › ### Fixedсо ссылкой(#161)в стиле соседних записей.[documentation] Поправить устаревший заголовок/комментарий теста про
startNewChat → cancelPendingAdoption—apps/client/src/features/ai-chat/hooks/use-chat-session.test.tsx:123-139. До правкиstartNewChatдействительно вызывалcancelPendingAdoption(); теперь он вызываетstartFreshThread(). Заголовок теста («startNewChat while already in a new chat: cancelPendingAdoption stops…») и комментарий («only the explicit cancelPendingAdoption() disarms») теперь противоречат изменённому коду — это doc-vs-code контрадикция, внесённая именно этой правкой (сам тест по-прежнему валиден:cancelPendingAdoptionостался и используется вselectChat). Fix: переименовать тест/комментарий, отвязав сценарий отstartNewChat(например, привязав кselectChat/ прямому вызовуcancelPendingAdoption), и добавить парный тест на разоружение черезstartFreshThread(см. ниже).[warning][test coverage] Добавить тест:
startFreshThread()разоружает уже взведённый error-path fallback —use-chat-session.test.tsx(рядом с:230-238). Обоснование удаленияcancelPendingAdoption()изstartNewChatцеликом держится на том, чтоstartFreshThreadобнуляетpendingNewChatRef(use-chat-session.ts:308). Это поведение нигде не проверяется: три новых теста проверяют смену mount-key, отклонение брошенного finish и адопцию при совпадающем ключе, но ни один не делает «взвести fallback →startFreshThread()→ поздний refetch → нет адопции». ЕслиstartFreshThreadкогда-нибудь перестанет разоружать, поздний refetch снова выдернет пользователя в только что упавший чат, а сьют останется зелёным. Fix: тест по образцу:123-139, но черезstartFreshThread()вместоcancelPendingAdoption().[suggestion][test coverage] Покрыть проброс
threadKeyизChatThreadпо веткеonError—chat-thread.tsx:264-285. Уchat-thread.tsxнет компонентных тестов; пробросthreadKeyи вonFinish(onTurnFinished(extractServerChatId(message), threadKey)), и вonError(onTurnFinished(undefined, threadKey)) ничем не зафиксирован. ОткатthreadKey → undefinedна веткеonErrorмолча переоткрыл бы баг #161 для ошибочного хода. Fix: лёгкий render-тестChatThread(мокuseChat), дёргающийonErrorи проверяющий вызов пропаonTurnFinished(undefined, threadKey).Test coverage
Новая логика (
startFreshThread, guard брошенного треда вonTurnFinished, пробросthreadKey) покрыта тремя юнит-тестами хука на уровне поведения. Незакрытые места: (1) разоружение fallback черезstartFreshThread— load-bearing для удаленияcancelPendingAdoptionизstartNewChat, не проверено; (2) пробросthreadKeyизChatThread(веткиonFinish/onError) — компонентных тестов нет. Back-compat путьfinishingThreadKey === undefinedпокрыт существующими одноаргументными вызовамиonTurnFinished.Architecture & design (forward-looking, non-blocking)
Множество guard'ов против поздних колбэков брошенного треда. Правка добавляет 6-й/7-й механизм (
threadKeyRef+ пробросfinishingThreadKey) к хуку, где уже есть несколько guard'ов (activeChatIdRefот двойного срабатывания,pendingNewChatReferror-path fallback, render-phase reconciler, history-latch) — все компенсируют одну причину:ai@6 useChatне прерывает запрос при unmount и продолжает проксироватьonFinish/onErrorпосле размонтирования. Сам автор отметил будущий seamisCurrentThread(key). Дефекта нет, вопрос — понятность и поверхность будущих багов.свести guard'ы к одному именованному seam
isCurrentThread(key)* без смены поведения. Effort: small. Pros: низкий риск, реализует идею автора, даёт следующему edge-case явное место. Cons: косметика — число механизмов не уменьшает.Полномочия на mount-key разделены между
startFreshThreadи render-phase reconciler. Оба могут диспатчитьreconcile → chatId:null. В случаеactiveChatId === null(баг #161) reconciler — no-op, ремонтит толькоstartFreshThread; доказательство отсутствия двойного ремонта живёт в комментарии, а не в структуре. Корректно, но «два источника правды для ключа» — ровно тот запах, который убирал thread-identity reducer. Рекомендация: оставить как есть —null → nullэто не-переход, который divergence-based reconciler структурно не видит, поэтому императивный nudge честен; вводить nonce ради элегантности не стоит. Surfaced лишь чтобы зафиксировать разделение полномочий — при выбореPull request closed