feat(ai-chat): прервать агента и отправить отложенное сообщение сейчас (#198) #203
Closed
Ghost
wants to merge 2 commits from
feat/198-interrupt-agent-send-now into develop
pull from: feat/198-interrupt-agent-send-now
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:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
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
feature
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#203
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 "feat/198-interrupt-agent-send-now"
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?
Что это
Кнопка «Send now» у каждого отложенного (queued) сообщения AI-чата: прерывает работающего агента, сохраняя его частичный вывод, и немедленно отправляет именно это сообщение. На следующем ходу агент получает пометку, что его прервали, и трактует свой предыдущий (частичный) ответ как незавершённый.
Closes #198.
Как работает
Частичный вывод уже сохранялся (
onAbort→aborted) и реплеился модели (findRecentне фильтрует по статусу) — этот путь не менялся. Добавлены только UI-кнопка, одноразовый флагinterruptedв теле запроса и условная заметка в системном промпте.Изменения
Клиент
queue-helpers.ts— чистыйpromoteToHead()(+ юнит-тесты).chat-thread.tsx—sendNow(), ветка слива на намеренном abort вonFinish, одноразовыйinterruptedвprepareSendMessagesRequest, сброс стейл-флагов на старте хода, кнопка-иконка в списке очереди.en-US/ru-RU— ключиSend now,Interrupt and send now.Сервер
ai-chat.service.ts—interruptedвAiChatStreamBody; чистыйshouldInjectInterruptNote()(гейтит по флагу и реально незавершённому предыдущему ходу —aborted/streaming); проброс вbuildSystemPrompt.ai-chat.prompt.ts—INTERRUPT_NOTEвнутри safety-сэндвича.Ревью
Пройдено внутреннее ревью; найдено и исправлено 2 Major (повторное ревью — APPROVE):
en-US/ru-RU(для ru-RU давало mixed-language) → добавлены.Проверка
footnote-definition-view.tsx, ��е из этого PR).@tanstack/eslint-plugin-query(падает на загрузке конфига, не связано с PR).🤖 Generated with Claude Code
82699f7a20tof789be9c89Code review — «Interrupt agent / send now» (#198)
Вердикт: 🔶 Request changes — функциональное ядро корректно (сервер уже валидирует
interrupted, штатные пути без гонок,promoteToHeadчист и покрыт тестами), но change вносит два объективных дефекта, которые стоит закрыть до мержа: незарегистрированные i18n-ключи и устаревший комментарий, противоречащий новому поведению. Критичных проблем нет.Объём: дифф против
develop(merge-basefbdb8aa1), 3 файла, +96/−13. Полное ревью — все 8 аспектов (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture).⛔ Должно быть исправлено до мержа
[conventions] Зарегистрировать новые
t()-строки в каталогах локалей —apps/client/src/features/ai-chat/components/chat-thread.tsx:514и:520Дифф добавляет два UI-лейбла —
t("Interrupt and send now")иt("Send now")— но ни один из ключей не зарегистрирован: проверено grep'ом, оба отсутствуют во всех локалях, включая исходныйen-USи активныйru-RU. Все соседние строки этого же компонента ("Remove queued message","Response stopped.") присутствуют вen-USиru-RU; extraction-тулинга (i18next-parser и т.п.) в проекте нет — каталоги ведутся вручную. Сейчас строки рендерятся только из-за фолбэка i18next на сам ключ и в других языках никогда не переведутся — это вносимый диффом долг.Fix: добавить ключи
"Interrupt and send now"и"Send now"вapps/client/public/locales/en-US/translation.json(источник) и перевод вru-RU/translation.json, как сделано для соседнего"Remove queued message".[documentation] Обновить устаревший блок-комментарий к состоянию
queued—chat-thread.tsx:185-187Комментарий гласит: «On Stop or error the queue is intentionally preserved (onFinish does not fire then)». Новый поток «Send now» делает обе части ложными:
sendNowсам зовётstop(), а новая ветка вonFinish(:285-291) на этом аборте делаетflushNext()— то есть осознанный Stop теперь сбрасывает очередь, а не сохраняет. Оговорка «onFinish does not fire then» прямо противоречит соседнему комментарию:269-276и новой логике, которая как раз опирается на срабатываниеonFinishпри аборте.Fix: убрать неверное «(onFinish does not fire then)» и уточнить: очередь сохраняется при обычном Stop/disconnect/error, но намеренный «Send now» (тоже зовущий
stop()) специально флашит продвинутую в голову запись через abort-веткуonFinish.[warning][stability] Защитить streaming-ветку
sendNowот гонки с устаревшимisStreaming—chat-thread.tsx:355-361sendNowветвится по захваченному в замыканииisStreaming(deps[isStreaming, setQueue, stop]), без сверки с живым статусом стора. Узкое окно гонки: если ход завершился между рендером и кликом,stop()становится no-op (срабатывает только приstreaming/submitted),onFinishповторно не вызывается, иflushOnAbortRefостаётся взведённым. Тогда следующий настоящий ручной Stop уйдёт в abort-веткуonFinish, подавит уведомление «stopped» и сделаетflushNext()— автоматически отправит сообщение из очереди, которое пользователь отправлять не просил (нарушение инварианта «на Stop очередь сохраняется»). УтечкаinterruptNextSendRefпри этом безвредна — сервер гейтит заметку. Предусловие маловероятно (клик ровно в субкадровом окне между завершением хода и ре-рендером), но дефект реальный, фикс дешёвый.Fix: в streaming-ветке
sendNowсверяться с живым статусом (например, держатьstatusRef, обновляемый каждый рендер) и армировать рефы только приsubmitted/streaming; иначе уходить в не-streaming ветку.✅ Покрытие тестами
promoteToHead— 3 юнит-теста (середина→голова, отсутствующий id, отсутствие мутации). Соответствует конвенции фичи «вынести чистое решение вutils/*.tsи протестировать».sendNow, interrupt-веткаonFinish, one-shot-семантика флагаinterrupted. Эта часть сплетена сuseChat(stop/onFinish/рефы), а проект осознанно не рендеритChatThreadв тестах (RTL-тестов компонента / моковuseChatвapps/clientнет) — оставить React-обвязку без тестов соответствует конвенции. Опционально: вынести решение терминального исходаonFinishв чистый хелпер (decideTerminalAction({ intentionalInterrupt, isAbort, isDisconnect, isError })) и покрыть 5 случаями.🏛 Архитектура
Координация очереди + прерывания в
chat-thread.tsxразмазана: состояние очереди и управление раскиданы по transport-useMemo,onFinishи обработчикам, склеены 4 рефами; два новых one-shot-флага (flushOnAbortRef,interruptNextSendRef) ставятся вместе вsendNow, но читаются-и-сбрасываются в разных колбэках в разное время. Инвариант «флаги одноразовые» держится на комментариях, не на структуре — тот же класс «разбросанных рефов жизненного цикла», который команда уже вынесла в этой фиче (hooks/use-chat-session.ts+utils/thread-identity.tsчерезuseReducer).вынести хук
useChatQueue* ({ queued, enqueue, removeQueued, sendNow, onTurnSettled }+consumeInterruptFlag()) (effort: medium). + со-локация рефов/флагов/решений в одном тестируемом юните по образцуuseChatSession; one-shot становится внутренним делом хука;chat-thread.tsxуменьшается. − хук всё равно седлает границуuseChat(нужныstop, актуальныйsendMessage, хук вonFinish/prepareSendMessagesRequest).Аспекты без замечаний
Security — LGTM (флаг
interruptedнезависимо ревалидируется вshouldInjectInterruptNote: нота вставляется только если предыдущий ход реальноaborted/streaming; нота — статическая константа, инъекции нет; эндпоинт под прежними guard'ами, секретов нет). Regressions — LGTM (новая веткаonFinishне срабатывает на обычных Stop/disconnect/error —flushOnAbortRefпо умолчаниюfalse; поля телаchatId/openPage/roleId/messagesсохранены,interruptedне конфликтует). Simplification — LGTM (два рефа оправданы — читаются в разные моменты жизненного цикла;promoteToHeadминимален).Code review (re-review) — PR #203: прерывание агента и немедленная отправка очередного сообщения (#198)
Вердикт: Approve. Оба прошлых блокера закрыты по существу: незарегистрированные ключи
t()теперь есть в обоих локалях, устаревший комментарий переписан под новое поведение. Дельта — оборонительное уточнение (live-status через ref, one-shot-флаги прерывания) без новых блокеров.Ре-ревью дельты
f789be9c..5b146fd2(1 файлов, +20/−5). Аспекты: security, stability, conventions, documentation, test-coverage (параллельные ревьюеры + judge).Статус прошлых блокеров
t("Interrupt and send now")/t("Send now")— закрыт. Ключи присутствуют вapps/client/public/locales/en-US/translation.json:1178-1179("Send now","Interrupt and send now") и вapps/client/public/locales/ru-RU/translation.json:718-719("Отправить сейчас","Прервать и отправить сейчас"); потребители —chat-thread.tsx:539(tooltip) и:545(aria-label).queuedпереписан: теперь явно сказано, чтоonFinishДЕЙСТВИТЕЛЬНО срабатывает на abort/disconnect/error, но оставляет очередь нетронутой, и описано единственное исключение — «Send now», которое флашит промотированную голову (chat-thread.tsx, дельта строк ~182–190).Must fix before merge
Нет.
Non-blocking
chat-thread.tsx:382(sendNow) +:389-400(useEffect). При истинной гонке (turn завершается чисто в том же тике, что и клик)flushOnAbortRef/interruptNextSendRefне потребляются ветками abort/send. Уже обезврежено: guardintentionalInterrupt && isAbortвonFinishи сброс обоих ref вuseEffect(isStreaming). Действий не требуется — отмечено для полноты.Test coverage
Дельта — оборонительное уточнение уже покрытого поведения (переход с closure-
isStreamingнаstatusRef, без изменения серверной поверхности). Полный PR несёт тесты на хелперы очереди и сервис (queue-helpers.test.ts,ai-chat.service.spec.ts,ai-chat.prompt.spec.ts). Сама веткаstatusRef-branch вsendNowюнит-тестом не покрыта, но это чисто клиентская защитная развилка поверх протестированной логики flush/interrupt — не блокер. Покрыто на достаточном уровне.Pull request closed