feat(ai-chat): interrupt agent + send queued message, keeping partial output (#198) #211

Merged
vvzvlad merged 3 commits from feat/198-interrupt-agent into develop 2026-06-26 20:55:20 +03:00

Closes #198

Lets the user interrupt a running agent and immediately send a queued message ("send now"), keeping the agent partial output.

Client

  • queue-helpers.ts: pure promoteToHead (+ unit tests).
  • chat-thread.tsx: sendNow (promote head -> abort -> flush-on-abort), one-shot flushOnAbortRef/interruptNextSendRef, interrupted flag in the request body, and a "send now" ActionIcon next to the existing clock/cross in the queued list.

Server

  • interrupted?: boolean on AiChatStreamBody; pure isInterruptResume confirms the client hint against persisted history (previous assistant turn aborted/streaming) before honouring it — a spoofed flag on an ordinary turn is ignored.
  • ai-chat.prompt.ts: INTERRUPT_NOTE injected into the context section (inside the safety sandwich) only on a confirmed interrupt-resume turn, so the model treats the partial answer above as incomplete. The partial output itself is already replayed from history (findRecent does not filter by status).

Tests (unit only)

  • client: promoteToHead; chat-thread send-now (abort + resend on abort, one-shot interrupt flag, non-streaming immediate send).
  • server: isInterruptResume (flag confirmation vs aborted/streaming/completed/non-assistant/no-prev); prompt interrupt-note present when interrupted, absent otherwise.

Verify

  • pnpm --filter server exec tsc --noEmit: clean
  • pnpm --filter client exec tsc -b: clean
  • server jest src/core/ai-chat/: 346 passed
  • client vitest (queue-helpers + chat-thread): 16 passed

🤖 Generated with Claude Code

Closes #198 Lets the user interrupt a running agent and immediately send a queued message ("send now"), keeping the agent partial output. ## Client - `queue-helpers.ts`: pure `promoteToHead` (+ unit tests). - `chat-thread.tsx`: `sendNow` (promote head -> abort -> flush-on-abort), one-shot `flushOnAbortRef`/`interruptNextSendRef`, `interrupted` flag in the request body, and a "send now" ActionIcon next to the existing clock/cross in the queued list. ## Server - `interrupted?: boolean` on `AiChatStreamBody`; pure `isInterruptResume` confirms the client hint against persisted history (previous assistant turn `aborted`/`streaming`) before honouring it — a spoofed flag on an ordinary turn is ignored. - `ai-chat.prompt.ts`: `INTERRUPT_NOTE` injected into the context section (inside the safety sandwich) only on a confirmed interrupt-resume turn, so the model treats the partial answer above as incomplete. The partial output itself is already replayed from history (findRecent does not filter by status). ## Tests (unit only) - client: `promoteToHead`; chat-thread send-now (abort + resend on abort, one-shot interrupt flag, non-streaming immediate send). - server: `isInterruptResume` (flag confirmation vs aborted/streaming/completed/non-assistant/no-prev); prompt interrupt-note present when interrupted, absent otherwise. ## Verify - `pnpm --filter server exec tsc --noEmit`: clean - `pnpm --filter client exec tsc -b`: clean - server jest `src/core/ai-chat/`: 346 passed - client vitest (queue-helpers + chat-thread): 16 passed 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-26 06:24:20 +03:00
Add a "send now" button to queued AI-chat messages: it interrupts the
running agent and immediately sends that message, while the agent's
partial output at interruption is kept in history and the next turn is
marked as a user interrupt.

Client:
- queue-helpers: pure `promoteToHead` to move a queued message to the head.
- chat-thread: `sendNow` (promote head + abort + flush-on-abort), one-shot
  `flushOnAbortRef`/`interruptNextSendRef`, `interrupted` flag in the
  request body, and the "send now" ActionIcon in the queued list.

Server:
- `interrupted` on AiChatStreamBody; pure `isInterruptResume` confirms the
  client hint against persisted history (prev assistant turn aborted/
  streaming) before honouring it.
- prompt: INTERRUPT_NOTE injected in the context section only on a
  confirmed interrupt-resume turn so the model treats the partial answer
  above as incomplete.

Tests: promoteToHead, chat-thread send-now (abort + resend + one-shot
interrupt flag + non-streaming immediate send), isInterruptResume, and
the prompt interrupt-note injection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added the feature label 2026-06-26 15:49:29 +03:00
Owner

Code review — PR #211: feat(ai-chat): прерывание агента и немедленная отправка сообщения из очереди (#198)

Вердикт: Request changes. Логика прерывания корректна и покрыта тестом, чистые хелперы (promoteToHead, isInterruptResume) вынесены и протестированы. Но must-fix: два новых i18n-ключа не зарегистрированы ни в одном каталоге локалей (нарушение задокументированной политики «en-US и ru-RU полностью поддерживаются, UI никогда не рендерит смешанный язык»), и в ai-chat.prompt.ts появился мёртвый export константы.

Объём: дифф developfeat/198-interrupt-agent (merge-base 3ddc329b), 8 файлов, +447/−13. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.

Must fix before merge

  • [i18n] Зарегистрировать ключи «Send now» и «Interrupt and send now» в каталогах en-US и ru-RUapps/client/src/features/ai-chat/components/chat-thread.tsx:521-529
    Два новых пользовательских ключа — t("Interrupt and send now") (tooltip) и t("Send now") (aria-label) — отсутствуют во всех каталогах локалей (проверено grep'ом: ни в en-US, ни в ru-RU). Соседние ключи очереди («Remove queued message» → «Убрать из очереди») зарегистрированы в обоих, так что это новое отклонение, а не baseline. Политика в apps/client/src/i18n.ts:15-23 явно требует держать набор строк AI-chat полным в обоих языках, «чтобы UI никогда не рендерил смешанный язык». Сейчас русский пользователь видит английские tooltip/aria-label рядом с уже переведённым рядом очереди — ровно тот mixed-language исход, который политика запрещает. Fix: добавить «Send now» и «Interrupt and send now» в apps/client/public/locales/en-US/translation.json (значение = английский текст ключа) и в ru-RU/translation.json с русскими переводами, по образцу «Remove queued message».

  • [simplification] Убрать неиспользуемый export { INTERRUPT_NOTE }, оставить константу module-privateapps/server/src/core/ai-chat/ai-chat.prompt.ts:75
    Новый export { INTERRUPT_NOTE }; экспонирует константу, но её никто не импортирует (grep по репозиторию не находит ссылок вне файла; спека ai-chat.prompt.spec.ts намеренно проверяет подстроку-маркер 'interrupted by the', а не импортирует константу). Соседние константы того же модуля, DEFAULT_PROMPT и SAFETY_FRAMEWORK, специально module-private. Это change-introduced мёртвая публичная поверхность, противоречащая конвенции самого модуля. Fix: удалить строку export { INTERRUPT_NOTE }; и объявить const INTERRUPT_NOTE = ... как private, по образцу DEFAULT_PROMPT/SAFETY_FRAMEWORK.

  • [regressions] Сбрасывать interruptNextSendRef в ветке flush-on-abort, когда отправлять нечегоapps/client/src/features/ai-chat/components/chat-thread.tsx:296-300, 363-373
    sendNow() взводит interruptNextSendRef=true и зовёт stop(). Флаг read-and-clear выполняется только внутри prepareSendMessagesRequest при реальной отправке. В ветке onFinish (flushOnAbortRef сбрасывается безусловно на :297), если flushNext() упирается в if (!head) return (пользователь удалил только что промоутнутое сообщение до приземления abort) — запрос не уходит, и interruptNextSendRef остаётся true, помечая СЛЕДУЮЩУЮ несвязанную отправку как interrupted:true. Серверный guard isInterruptResume нейтрализует видимый эффект в типовом случае, а предусловие надуманное — поэтому non-blocking, но залипший one-shot флаг остаётся латентной утечкой между ходами. Fix: в ветке flush-on-abort сбрасывать interruptNextSendRef.current=false рядом с flushOnAbortRef (или безусловно там же), чтобы тег прерывания не пережил несвязанную последующую отправку.

  • [documentation] Добавить запись в CHANGELOG [Unreleased]/Added про фичу #198CHANGELOG.md
    Проект ведёт Keep-a-Changelog с ## [Unreleased] / ### Added, обновляемым по-фично, и уже ссылается на номера issue соседних AI-chat фич (#143, #166, #168, #174, #180, #183). Эта PR поставляет пользовательскую фичу (прерывание агента + немедленная отправка из очереди, #198), но записи нет — #198 не встречается в файле. AGENTS.md:291 трактует CHANGELOG как release-time сборку из git log, поэтому это мягкая конвенция, не hard rule — non-blocking. Fix: добавить короткий ### Added буллет под ## [Unreleased] со ссылкой (#198), в стиле существующих AI-chat записей.

Test coverage

Вся новая логика покрыта. Чистые хелперы (promoteToHead, isInterruptResume) вынесены и юнит-тестированы; флоу прерывания end-to-end проверяется в chat-thread.test.tsx (мокает useChat, ai-transport и дочерние компоненты); серверная injection INTERRUPT_NOTE покрыта ai-chat.prompt.spec.ts через подстроку-маркер. Замечание: ассерт getByLabelText("Send now") проходит только из-за raw-key fallback i18next, что маскирует пропуск ключей в каталогах (см. must-fix выше) — после регистрации ключей тест продолжит проходить.

Architecture & design (forward-looking, non-blocking)

Координация in-mount очереди + «send now» в ChatThread (apps/client/src/features/ai-chat/components/chat-thread.tsx). ChatThread (≈483 строк) разводит флоу прерывания по четырём несвязным точкам, сшитым мутабельными рефами: sendNow() промоутит цель и взводит flushOnAbortRef+interruptNextSendRef, затем stop(); transport-замыкание (prepareSendMessagesRequest) read-and-clear читает interruptNextSendRef; стабильный onFinish читает flushOnAbortRef и ветвится в flushNext(); flushNext() снова входит в transport. Итого пять координирующих рефов реализуют небольшую state-machine abort→promote→resend→tag прямо в компоненте, который ВДОБАВОК владеет жизненным циклом useChat, ранним adoption chat-id, throttled live-token репортингом, stop-уведомлениями и рендером role-card. Чистые куски вынесены и протестированы; stateful-склейка — нет, и проверяется только через тяжёлый mock-everything тест компонента.

Оставить inline как есть**, опционально добавить короткий ASCII-комментарий, называющий четыре точки касания state-machine (effort: s). Pros: нулевой поведенческий риск; флоу уже плотно прокомментирован, чистая логика уже вынесена/протестирована; избегает спекулятивной абстракции для фичи, которая может не вырасти. Cons: следующее поведение очереди/прерывания продолжит накапливать рефы и ветки onFinish в 483-строчном компоненте; stateful-склейка остаётся тестируемой только через mock-everything тест.

## Code review — PR #211: feat(ai-chat): прерывание агента и немедленная отправка сообщения из очереди (#198) **Вердикт: Request changes.** Логика прерывания корректна и покрыта тестом, чистые хелперы (`promoteToHead`, `isInterruptResume`) вынесены и протестированы. Но must-fix: два новых i18n-ключа не зарегистрированы ни в одном каталоге локалей (нарушение задокументированной политики «en-US и ru-RU полностью поддерживаются, UI никогда не рендерит смешанный язык»), и в `ai-chat.prompt.ts` появился мёртвый `export` константы. _Объём: дифф `develop`…`feat/198-interrupt-agent` (merge-base `3ddc329b`), 8 файлов, +447/−13. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход._ ### Must fix before merge - **[i18n] Зарегистрировать ключи «Send now» и «Interrupt and send now» в каталогах en-US и ru-RU** — `apps/client/src/features/ai-chat/components/chat-thread.tsx:521-529` Два новых пользовательских ключа — `t("Interrupt and send now")` (tooltip) и `t("Send now")` (aria-label) — отсутствуют во всех каталогах локалей (проверено grep'ом: ни в en-US, ни в ru-RU). Соседние ключи очереди («Remove queued message» → «Убрать из очереди») зарегистрированы в обоих, так что это новое отклонение, а не baseline. Политика в `apps/client/src/i18n.ts:15-23` явно требует держать набор строк AI-chat полным в обоих языках, «чтобы UI никогда не рендерил смешанный язык». Сейчас русский пользователь видит английские tooltip/aria-label рядом с уже переведённым рядом очереди — ровно тот mixed-language исход, который политика запрещает. Fix: добавить «Send now» и «Interrupt and send now» в `apps/client/public/locales/en-US/translation.json` (значение = английский текст ключа) и в `ru-RU/translation.json` с русскими переводами, по образцу «Remove queued message». - **[simplification] Убрать неиспользуемый `export { INTERRUPT_NOTE }`, оставить константу module-private** — `apps/server/src/core/ai-chat/ai-chat.prompt.ts:75` Новый `export { INTERRUPT_NOTE };` экспонирует константу, но её никто не импортирует (grep по репозиторию не находит ссылок вне файла; спека `ai-chat.prompt.spec.ts` намеренно проверяет подстроку-маркер `'interrupted by the'`, а не импортирует константу). Соседние константы того же модуля, `DEFAULT_PROMPT` и `SAFETY_FRAMEWORK`, специально module-private. Это change-introduced мёртвая публичная поверхность, противоречащая конвенции самого модуля. Fix: удалить строку `export { INTERRUPT_NOTE };` и объявить `const INTERRUPT_NOTE = ...` как private, по образцу `DEFAULT_PROMPT`/`SAFETY_FRAMEWORK`. - **[regressions] Сбрасывать `interruptNextSendRef` в ветке flush-on-abort, когда отправлять нечего** — `apps/client/src/features/ai-chat/components/chat-thread.tsx:296-300, 363-373` `sendNow()` взводит `interruptNextSendRef=true` и зовёт `stop()`. Флаг read-and-clear выполняется только внутри `prepareSendMessagesRequest` при реальной отправке. В ветке onFinish (`flushOnAbortRef` сбрасывается безусловно на :297), если `flushNext()` упирается в `if (!head) return` (пользователь удалил только что промоутнутое сообщение до приземления abort) — запрос не уходит, и `interruptNextSendRef` остаётся `true`, помечая СЛЕДУЮЩУЮ несвязанную отправку как `interrupted:true`. Серверный guard `isInterruptResume` нейтрализует видимый эффект в типовом случае, а предусловие надуманное — поэтому non-blocking, но залипший one-shot флаг остаётся латентной утечкой между ходами. Fix: в ветке flush-on-abort сбрасывать `interruptNextSendRef.current=false` рядом с `flushOnAbortRef` (или безусловно там же), чтобы тег прерывания не пережил несвязанную последующую отправку. - **[documentation] Добавить запись в CHANGELOG `[Unreleased]/Added` про фичу #198** — `CHANGELOG.md` Проект ведёт Keep-a-Changelog с `## [Unreleased] / ### Added`, обновляемым по-фично, и уже ссылается на номера issue соседних AI-chat фич (#143, #166, #168, #174, #180, #183). Эта PR поставляет пользовательскую фичу (прерывание агента + немедленная отправка из очереди, #198), но записи нет — #198 не встречается в файле. AGENTS.md:291 трактует CHANGELOG как release-time сборку из git log, поэтому это мягкая конвенция, не hard rule — non-blocking. Fix: добавить короткий `### Added` буллет под `## [Unreleased]` со ссылкой `(#198)`, в стиле существующих AI-chat записей. ### Test coverage Вся новая логика покрыта. Чистые хелперы (`promoteToHead`, `isInterruptResume`) вынесены и юнит-тестированы; флоу прерывания end-to-end проверяется в `chat-thread.test.tsx` (мокает `useChat`, ai-transport и дочерние компоненты); серверная injection `INTERRUPT_NOTE` покрыта `ai-chat.prompt.spec.ts` через подстроку-маркер. Замечание: ассерт `getByLabelText("Send now")` проходит только из-за raw-key fallback i18next, что маскирует пропуск ключей в каталогах (см. must-fix выше) — после регистрации ключей тест продолжит проходить. ### Architecture & design (forward-looking, non-blocking) **Координация in-mount очереди + «send now» в `ChatThread`** (`apps/client/src/features/ai-chat/components/chat-thread.tsx`). ChatThread (≈483 строк) разводит флоу прерывания по четырём несвязным точкам, сшитым мутабельными рефами: `sendNow()` промоутит цель и взводит `flushOnAbortRef`+`interruptNextSendRef`, затем `stop()`; transport-замыкание (`prepareSendMessagesRequest`) read-and-clear читает `interruptNextSendRef`; стабильный `onFinish` читает `flushOnAbortRef` и ветвится в `flushNext()`; `flushNext()` снова входит в transport. Итого пять координирующих рефов реализуют небольшую state-machine abort→promote→resend→tag прямо в компоненте, который ВДОБАВОК владеет жизненным циклом `useChat`, ранним adoption chat-id, throttled live-token репортингом, stop-уведомлениями и рендером role-card. Чистые куски вынесены и протестированы; stateful-склейка — нет, и проверяется только через тяжёлый mock-everything тест компонента. Оставить inline как есть**, опционально добавить короткий ASCII-комментарий, называющий четыре точки касания state-machine (effort: s). Pros: нулевой поведенческий риск; флоу уже плотно прокомментирован, чистая логика уже вынесена/протестирована; избегает спекулятивной абстракции для фичи, которая может не вырасти. Cons: следующее поведение очереди/прерывания продолжит накапливать рефы и ветки onFinish в 483-строчном компоненте; stateful-склейка остаётся тестируемой только через mock-everything тест.
Ghost added 1 commit 2026-06-26 17:19:31 +03:00
- Register the new AI-chat keys "Send now" and "Interrupt and send now" in
  both en-US and ru-RU catalogs so the UI never renders mixed-language
  tooltip/aria-label (i18n policy).
- Make INTERRUPT_NOTE module-private (drop the unused re-export), matching the
  module's private DEFAULT_PROMPT/SAFETY_FRAMEWORK siblings.
- Reset interruptNextSendRef in the flush-on-abort branch when nothing is
  actually sent, so a stuck one-shot interrupt flag cannot tag the next
  unrelated send; flushNext now reports whether it sent.
- Add a CHANGELOG [Unreleased]/Added entry for #198.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost added 1 commit 2026-06-26 17:39:03 +03:00
Port the only substantive fix #211 was missing relative to #203 (which is
being closed): the "Send now" handler branched on the closure-captured
isStreaming, but a turn can finish between render and click. In that window
stop() is a no-op, so arming flushOnAbortRef/interruptNextSendRef would strand
those one-shot flags and leak into a later, unrelated Stop (auto-sending a
queued message the user never asked to send).

- Mirror the live useChat status in statusRef (updated each render) and branch
  sendNow on it instead of isStreaming, so the not-streaming path runs when the
  turn has already ended and the interrupt flags are never armed against a
  no-op stop().
- Belt-and-suspenders: clear flushOnAbortRef/interruptNextSendRef when a new
  turn starts streaming, defusing the sub-render-tick window where a flag could
  still be armed but the expected abort never fired. No-op for the legit
  interrupt path (both refs are consumed synchronously beforehand).

Keeps #211's existing structure and its flushNext-returns-boolean fix. The
rest of #203's divergence is comment rewording, a server-side rename of the
same pure interrupt-gate, and fewer tests — nothing else to port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner

Code review (re-review) — PR #211: прерывание AI-агента и отправка сообщения из очереди «сейчас»

Вердикт: Approve. Оба прошлых блокера закрыты по существу; дельта добавляет только защиту от утечки одноразового тега прерывания и не вносит новых проблем.

Ре-ревью дельты 50a96271..cbd877e1 (5 файлов, +20/−4). Аспекты: stability, conventions, documentation, test-coverage (параллельные ревьюеры + judge).

Статус прошлых блокеров

  • i18n-ключи «Send now» / «Interrupt and send now» — закрыт. Оба ключа зарегистрированы в en-US/translation.json и ru-RU/translation.json (с переводами «Отправить сейчас» / «Прервать и отправить сейчас») и реально используются в chat-thread.tsx:528,534 через t(...).
  • Лишний export { INTERRUPT_NOTE } — закрыт. Экспорт удалён; константа осталась module-private и используется внутри файла (ai-chat.prompt.ts:191). Спека ссылается на неё только в комментарии, импорта нет — удаление безопасно, сборка/тесты не ломаются.

Must fix before merge

Нет.

Non-blocking

Нет.

Test coverage

Покрыто по существу. Серверная логика инъекции INTERRUPT_NOTE покрыта ai-chat.prompt.spec.ts:250 (инъекция при interrupted: true, отсутствие при false/absent). Единственная новая логика дельты — flushNext() возвращает boolean и при «пустом» dequeue (chat-thread.tsx:307) сбрасывает interruptNextSendRef, чтобы одноразовый тег не утёк на следующую отправку; это узкий клиентский guard-путь, согласованный с существующим one-shot-сбросом тега в prepareSendMessagesRequest (строки 250–251), и его отсутствие в unit-тестах не является блокером.

## Code review (re-review) — PR #211: прерывание AI-агента и отправка сообщения из очереди «сейчас» **Вердикт: Approve.** Оба прошлых блокера закрыты по существу; дельта добавляет только защиту от утечки одноразового тега прерывания и не вносит новых проблем. _Ре-ревью дельты `50a96271..cbd877e1` (5 файлов, +20/−4). Аспекты: stability, conventions, documentation, test-coverage (параллельные ревьюеры + judge)._ ### Статус прошлых блокеров - **i18n-ключи «Send now» / «Interrupt and send now»** — закрыт. Оба ключа зарегистрированы в `en-US/translation.json` и `ru-RU/translation.json` (с переводами «Отправить сейчас» / «Прервать и отправить сейчас») и реально используются в `chat-thread.tsx:528,534` через `t(...)`. - **Лишний `export { INTERRUPT_NOTE }`** — закрыт. Экспорт удалён; константа осталась module-private и используется внутри файла (`ai-chat.prompt.ts:191`). Спека ссылается на неё только в комментарии, импорта нет — удаление безопасно, сборка/тесты не ломаются. ### Must fix before merge Нет. ### Non-blocking Нет. ### Test coverage Покрыто по существу. Серверная логика инъекции INTERRUPT_NOTE покрыта `ai-chat.prompt.spec.ts:250` (инъекция при `interrupted: true`, отсутствие при `false/absent`). Единственная новая логика дельты — `flushNext()` возвращает `boolean` и при «пустом» dequeue (`chat-thread.tsx:307`) сбрасывает `interruptNextSendRef`, чтобы одноразовый тег не утёк на следующую отправку; это узкий клиентский guard-путь, согласованный с существующим one-shot-сбросом тега в `prepareSendMessagesRequest` (строки 250–251), и его отсутствие в unit-тестах не является блокером.
Ghost force-pushed feat/198-interrupt-agent from 3fa0c67fc6 to 686c3f9d14 2026-06-26 20:42:25 +03:00 Compare
vvzvlad merged commit ee78a96803 into develop 2026-06-26 20:55:20 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#211