fix(ai-chat): reset chat on New chat during the first turn's stream (#161) #162
Reference in New Issue
Block 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