AI-чат: прервать агента сообщением, сохранив частичный вывод («отправить прям щас») #198

Closed
opened 2026-06-25 22:42:14 +03:00 by Ghost · 0 comments

Задача

Дать пользователю возможность прервать работающего агента и тут же отправить ему отложенное (queued) сообщение, при этом:

  • частичный вывод агента на момент прерывания не теряется, а сохраняется в историю;
  • на следующем шаге агент получает этот частичный вывод плюс пометку, что пользователь его прервал своим сообщением;
  • технически это кнопка «отправить прям щас» у отложенного сообщения (рядом с уже существующими «часиками» и крестиком).

Как это устроено сейчас (результат исследования)

Клиент — очередь «отправить, пока агент занят». В apps/client/src/features/ai-chat/components/chat-thread.tsx есть локальная FIFO-очередь (queued/queuedRef, помощники в apps/client/src/features/ai-chat/utils/queue-helpers.ts). Пока идёт стрим, ввод в chat-input.tsx не отправляет, а кладёт текст в очередь (onQueue). Очередь «сливается» по одному сообщению в onFinishтолько при чистом завершении; при Stop/обрыве/ошибке (isAbort || isDisconnect || isError) flushNext() намеренно не вызывается, очередь сохраняется. Очередь рисуется списком с иконкой-часами и кнопкой-крестиком. Кнопки «отправить сейчас» сейчас нет.

Сервер — частичный вывод уже сохраняется. В apps/server/src/core/ai-chat/ai-chat.service.ts реализована пошаговая устойчивость (#183): строка ассистента создаётся заранее в статусе streaming, обновляется по мере завершения каждого шага и финализируется один раз. При прерывании срабатывает onAbort, который сохраняет частичный ответ (завершённые шаги + текст текущего шага) со статусом aborted и finishReason: 'aborted'. Колонка status (streaming|completed|error|aborted) добавлена миграцией 20260626T120000-ai-chat-message-status.ts.

Сервер — история восстанавливается из БД, независимо от статуса. Каждый ход модель видит не клиентский payload, а серверную историю: findRecent(...) грузит последние 50 сообщений без фильтра по status (apps/server/src/database/repos/ai-chat/ai-chat-message.repo.ts), затем convertToModelMessages. Прерванная (aborted) строка ассистента с её частичными metadata.parts попадёт в контекст следующего хода автоматически. Незавершённые tool-call'ы прерванного шага в capturedSteps не попадают (туда кладут только завершённые шаги в onStepFinish), поэтому риска MissingToolResultsError нет; завершённые, но беспарные вызовы assistantParts закрывает синтетическим output-error.

Вывод из исследования

Бóльшая часть требуемого уже есть. Частичный вывод сохраняется (onAbortaborted) и реплеится модели (findRecent без фильтра по статусу). Не хватает ровно двух вещей:

  1. UI-кнопки «отправить прям щас» у отложенного сообщения, которая прерывает текущий ход и немедленно шлёт именно это сообщение;
  2. пометки в контексте следующего хода «тебя прервал пользователь, твой предыдущий ответ неполон».

Архитектура решения

Поток по шагам (пользователь нажал «отправить прям щас» на отложенном сообщении X):

[клиент] promoteToHead(queue, X)  // X становится головой очереди
[клиент] flushOnAbort = true; interruptNextSend = true
[клиент] stop()                   // прерываем текущий ход
   ↓ abort долетает до сервера по закрытию SSE-сокета
[сервер] onAbort → finalizeAssistant(... 'aborted')   // частичный вывод сохранён
   ↓ на клиенте ход завершился
[клиент] onFinish({isAbort:true}) → flushOnAbort? → flushNext()  // шлём X
[клиент] prepareSendMessagesRequest → body.interrupted = true
   ↓ новый POST /api/ai-chat/stream
[сервер] stream(): вставляет user-сообщение X, грузит историю (там aborted-ответ),
         interrupted && предыдущий ход aborted → добавляет INTERRUPT_NOTE в системный промпт
[сервер] модель видит: …(частичный aborted-ответ), (user: X) + note «тебя прервали»

Ключевая идея — переиспользовать существующий механизм очереди и слива: «отправить сейчас» = «поставить X в голову очереди + прервать ход + слить голову на onAbort вместо игнорирования». Серверная пометка — по образцу уже имеющегося FINAL_STEP_INSTRUCTION, который конкатенируется к системному промпту.

Клиент

A. queue-helpers.ts — новый чистый помощник (в стиле существующих, с юнит-тестами):

/** Move the queued message with the given id to the FRONT (returns a new array).
 *  No-op (returns an equivalent array) when the id is absent. Pure. */
export function promoteToHead(
  queue: QueuedMessage[],
  id: string,
): QueuedMessage[] {
  const target = queue.find((m) => m.id === id);
  if (!target) return queue;
  return [target, ...queue.filter((m) => m.id !== id)];
}

B. chat-thread.tsx — логика «отправить сейчас»:

// Two single-flight refs (not state — read inside callbacks/transport without
// re-render and stale closures).
const flushOnAbortRef = useRef(false);      // flush the head on the abort we triggered
const interruptNextSendRef = useRef(false); // tag the next send as a user interrupt

// "Send now" on a queued message: interrupt the current turn and immediately
// send THIS message. Other queued messages stay queued (flushed normally later).
const sendNow = useCallback(
  (id: string) => {
    if (isStreaming) {
      // Promote to head so the existing onFinish→flushNext sends exactly it.
      setQueue(promoteToHead(queuedRef.current, id));
      flushOnAbortRef.current = true;
      interruptNextSendRef.current = true;
      stop(); // → onFinish({isAbort:true}) below flushes the head
    } else {
      // Nothing to interrupt: just send it now (no interrupt note).
      const msg = queuedRef.current.find((m) => m.id === id);
      if (!msg) return;
      setQueue(removeQueuedById(queuedRef.current, id));
      sendMessageRef.current?.({ text: msg.text });
    }
  },
  [isStreaming, setQueue, stop],
);

В onFinish — добавить ветку слива при «нашем» abort'е (раньше там был return):

// We triggered this abort via "Send now": flush the promoted head even though
// the turn was aborted (the normal abort path keeps the queue intact).
if (flushOnAbortRef.current) {
  flushOnAbortRef.current = false;
  // Suppress the "stopped" flash for an intentional interrupt+resend.
  setStopNotice(null);
  flushNext();           // interruptNextSendRef is consumed by the transport
  return;
}
if (isAbort || isDisconnect || isError) return;
flushNext();

В prepareSendMessagesRequest — read-and-clear флага, чтобы пометку нёс только этот запрос:

prepareSendMessagesRequest: ({ messages, body }) => {
  const interrupted = interruptNextSendRef.current;
  interruptNextSendRef.current = false; // one-shot
  return {
    body: {
      ...body,
      chatId: chatIdRef.current,
      openPage: openPageRef.current,
      roleId: roleIdRef.current,
      interrupted, // server-side flag (see Server section)
      messages,
    },
  };
},

C. Кнопка в списке очереди — добавить ActionIcon рядом с крестиком:

<Tooltip label={t("Interrupt and send now")} withArrow>
  <ActionIcon size="xs" variant="subtle" color="blue"
    onClick={() => sendNow(m.id)} aria-label={t("Send now")}>
    <IconPlayerPlayFilled size={12} />
  </ActionIcon>
</Tooltip>

Строки t("Send now") / t("Interrupt and send now") — английский исходник как ключ (i18n здесь Crowdin-managed, отдельный JSON править не нужно).

Сервер

A. ai-chat.service.ts:

  • В AiChatStreamBody добавить interrupted?: boolean.
  • В stream() после загрузки history вычислить факт прерывания и подтвердить его историей (защита от ложного/подменённого флага):
// The just-inserted user row is the tail; the turn before it is history[len-2].
// Treat the new turn as an interrupt-resume only when the client said so AND the
// preceding assistant turn really ended unfinished ('aborted', or still
// 'streaming' if onAbort has not finalized it yet — the abort/resend race).
const prev = history[history.length - 2];
const interrupted =
  body.interrupted === true &&
  prev?.role === 'assistant' &&
  (prev.status === 'aborted' || prev.status === 'streaming');
  • Передать interrupted в buildSystemPrompt({ ... , interrupted }).

B. ai-chat.prompt.ts: добавить поле в BuildSystemPromptInput и врезку в секцию context (внутри safety-сэндвича, перед завершающим SAFETY_FRAMEWORK):

// Injected only on the turn that immediately follows a user interruption, so the
// model treats the partial assistant message above as incomplete and continues
// from the user's new instruction instead of assuming it had finished.
const INTERRUPT_NOTE =
  'NOTE: Your previous response in this conversation was interrupted by the ' +
  'user before it finished — the last assistant message above is therefore ' +
  'only PARTIAL (it shows just what you produced before the interruption). The ' +
  'user has now sent a new message. Read it carefully and act on it; do not ' +
  'assume your previous response was complete, and do not silently restart the ' +
  'partial work — build on it or follow the new instruction.';
// ...
if (interrupted) context += `\n${INTERRUPT_NOTE}`;

Частичный вывод модели подавать не нужно отдельно — он уже в истории (findRecentaborted-строка с metadata.parts). INTERRUPT_NOTE — это и есть «комментарий, что пользователь прервал».

Краевые случаи (учтены в дизайне)

  1. Гонка onAbort ↔ загрузка истории нового хода. Новый POST может прийти раньше, чем onAbort финализирует строку в aborted. Не критично: благодаря #183 строка ассистента существует заранее (streaming) и обновляется по шагам, а findRecent статус не фильтрует — частичный вывод всегда доступен; в худшем случае не успеет попасть лишь последний фрагмент текущего шага (несколько токенов). Поэтому в условие включён и статус streaming.
  2. «Отправить сейчас», когда стрим уже закончился. isStreaming === false → ветка else: просто шлём сообщение, без abort и без пометки.
  3. Остальные отложенные сообщения. Остаются в очереди и сольются штатно после завершения нового хода (обычный flushNext на чистом onFinish).
  4. Двойные/частые клики. flushOnAbortRef/interruptNextSendRef — однократные (read-and-clear); повторный клик до оседания хода переустановит голову очереди, лишнего слива не будет.
  5. Беспарные tool-call'ы прерванного хода. Не попадают в capturedSteps, поэтому история остаётся валидной; завершённые беспарные вызовы уже закрываются output-error в assistantParts.
  6. Мигание «Response stopped.». Для намеренного interrupt'а гасим stopNotice в ветке flushOnAbortRef, чтобы не моргало перед стартом нового хода.
  7. Безопасность пометки. Флаг interrupted подтверждается серверной историей (предыдущий ход реально aborted/streaming), иначе игнорируется — подменённый флаг на обычном ходе ничего не инжектит.
  8. Язык. INTERRUPT_NOTE — на английском (как весь системный промпт); модель отвечает на языке пользователя по уже имеющейся инструкции.
  9. a11y/i18n. У кнопки aria-label + tooltip; строки через t(...).

Принятые решения и альтернативы

  • Куда класть пометку? Выбран системный промпт (по образцу FINAL_STEP_INSTRUCTION) — кросс-провайдерно безопасно (z.ai/OpenAI/Anthropic и т.д.), минимально инвазивно, само исчезает на следующем ходу (флаг ставится только для interrupt-хода). Альтернатива — врезать note префиксом в модельное представление user-сообщения (локальнее «по месту», но требует возни с формой ModelMessage.content). Обе эквивалентны по эффекту; рекомендуется первая.
  • Триггер пометки — явный флаг, а не только авто-детект. Можно было бы инжектить note всегда, когда предыдущий ход aborted (покрыло бы и «Stop → потом дописал»). Но это даёт ложные срабатывания на несвязанном сообщении много позже. Поэтому: флаг от кнопки + подтверждение историей. Это точно соответствует формулировке «прервал его сообщением».

План реализации (декомпозиция)

  1. queue-helpers.ts: promoteToHead + юнит-тесты в queue-helpers.test.ts.
  2. chat-thread.tsx: рефы flushOnAbortRef/interruptNextSendRef, sendNow, ветка слива в onFinish, interrupted в prepareSendMessagesRequest, кнопка в списке очереди, проброс sendNow.
  3. Сервер: interrupted в AiChatStreamBody; вычисление+подтверждение interrupted в stream(); проброс в buildSystemPrompt.
  4. ai-chat.prompt.ts: interrupted в BuildSystemPromptInput + INTERRUPT_NOTE.
  5. Тесты: ai-chat.prompt.spec.ts (note есть при interrupted:true, нет при false); ai-chat.service.spec.ts (флаг без aborted-предшественника → note не инжектится; с aborted/streaming → инжектится); опционально — клиентский тест на promoteToHead и формирование body.interrupted.

Затронутые файлы

  • apps/client/src/features/ai-chat/utils/queue-helpers.ts (+ .test.ts)
  • apps/client/src/features/ai-chat/components/chat-thread.tsx
  • apps/server/src/core/ai-chat/ai-chat.service.ts
  • apps/server/src/core/ai-chat/ai-chat.prompt.ts (+ .spec.ts)
  • apps/server/src/core/ai-chat/ai-chat.service.spec.ts

Дизайн подготовлен агентом-архитектором; реализация — отдельной задачей (ветка от свежего develop).

## Задача Дать пользователю возможность **прервать работающего агента и тут же отправить ему отложенное (queued) сообщение**, при этом: - частичный вывод агента на момент прерывания **не теряется**, а сохраняется в историю; - на следующем шаге агент получает этот частичный вывод **плюс пометку, что пользователь его прервал своим сообщением**; - технически это **кнопка «отправить прям щас»** у отложенного сообщения (рядом с уже существующими «часиками» и крестиком). ## Как это устроено сейчас (результат исследования) **Клиент — очередь «отправить, пока агент занят».** В `apps/client/src/features/ai-chat/components/chat-thread.tsx` есть локальная FIFO-очередь (`queued`/`queuedRef`, помощники в `apps/client/src/features/ai-chat/utils/queue-helpers.ts`). Пока идёт стрим, ввод в `chat-input.tsx` **не отправляет**, а кладёт текст в очередь (`onQueue`). Очередь «сливается» по одному сообщению в `onFinish` — **только при чистом завершении**; при `Stop`/обрыве/ошибке (`isAbort || isDisconnect || isError`) `flushNext()` намеренно не вызывается, очередь сохраняется. Очередь рисуется списком с иконкой-часами и кнопкой-крестиком. **Кнопки «отправить сейчас» сейчас нет.** **Сервер — частичный вывод уже сохраняется.** В `apps/server/src/core/ai-chat/ai-chat.service.ts` реализована пошаговая устойчивость (#183): строка ассистента создаётся **заранее** в статусе `streaming`, обновляется по мере завершения каждого шага и финализируется один раз. При прерывании срабатывает `onAbort`, который сохраняет частичный ответ (завершённые шаги + текст текущего шага) со статусом `aborted` и `finishReason: 'aborted'`. Колонка `status` (`streaming|completed|error|aborted`) добавлена миграцией `20260626T120000-ai-chat-message-status.ts`. **Сервер — история восстанавливается из БД, независимо от статуса.** Каждый ход модель видит не клиентский payload, а серверную историю: `findRecent(...)` грузит последние 50 сообщений **без фильтра по `status`** (`apps/server/src/database/repos/ai-chat/ai-chat-message.repo.ts`), затем `convertToModelMessages`. Прерванная (`aborted`) строка ассистента с её частичными `metadata.parts` **попадёт в контекст следующего хода автоматически**. Незавершённые tool-call'ы прерванного шага в `capturedSteps` не попадают (туда кладут только завершённые шаги в `onStepFinish`), поэтому риска `MissingToolResultsError` нет; завершённые, но беспарные вызовы `assistantParts` закрывает синтетическим `output-error`. ### Вывод из исследования **Бóльшая часть требуемого уже есть.** Частичный вывод сохраняется (`onAbort` → `aborted`) и реплеится модели (`findRecent` без фильтра по статусу). Не хватает ровно двух вещей: 1. **UI-кнопки** «отправить прям щас» у отложенного сообщения, которая прерывает текущий ход и немедленно шлёт именно это сообщение; 2. **пометки** в контексте следующего хода «тебя прервал пользователь, твой предыдущий ответ неполон». ## Архитектура решения Поток по шагам (пользователь нажал «отправить прям щас» на отложенном сообщении X): ``` [клиент] promoteToHead(queue, X) // X становится головой очереди [клиент] flushOnAbort = true; interruptNextSend = true [клиент] stop() // прерываем текущий ход ↓ abort долетает до сервера по закрытию SSE-сокета [сервер] onAbort → finalizeAssistant(... 'aborted') // частичный вывод сохранён ↓ на клиенте ход завершился [клиент] onFinish({isAbort:true}) → flushOnAbort? → flushNext() // шлём X [клиент] prepareSendMessagesRequest → body.interrupted = true ↓ новый POST /api/ai-chat/stream [сервер] stream(): вставляет user-сообщение X, грузит историю (там aborted-ответ), interrupted && предыдущий ход aborted → добавляет INTERRUPT_NOTE в системный промпт [сервер] модель видит: …(частичный aborted-ответ), (user: X) + note «тебя прервали» ``` Ключевая идея — **переиспользовать существующий механизм очереди и слива**: «отправить сейчас» = «поставить X в голову очереди + прервать ход + слить голову на `onAbort` вместо игнорирования». Серверная пометка — по образцу уже имеющегося `FINAL_STEP_INSTRUCTION`, который конкатенируется к системному промпту. ### Клиент **A. `queue-helpers.ts` — новый чистый помощник** (в стиле существующих, с юнит-тестами): ```ts /** Move the queued message with the given id to the FRONT (returns a new array). * No-op (returns an equivalent array) when the id is absent. Pure. */ export function promoteToHead( queue: QueuedMessage[], id: string, ): QueuedMessage[] { const target = queue.find((m) => m.id === id); if (!target) return queue; return [target, ...queue.filter((m) => m.id !== id)]; } ``` **B. `chat-thread.tsx` — логика «отправить сейчас»:** ```ts // Two single-flight refs (not state — read inside callbacks/transport without // re-render and stale closures). const flushOnAbortRef = useRef(false); // flush the head on the abort we triggered const interruptNextSendRef = useRef(false); // tag the next send as a user interrupt // "Send now" on a queued message: interrupt the current turn and immediately // send THIS message. Other queued messages stay queued (flushed normally later). const sendNow = useCallback( (id: string) => { if (isStreaming) { // Promote to head so the existing onFinish→flushNext sends exactly it. setQueue(promoteToHead(queuedRef.current, id)); flushOnAbortRef.current = true; interruptNextSendRef.current = true; stop(); // → onFinish({isAbort:true}) below flushes the head } else { // Nothing to interrupt: just send it now (no interrupt note). const msg = queuedRef.current.find((m) => m.id === id); if (!msg) return; setQueue(removeQueuedById(queuedRef.current, id)); sendMessageRef.current?.({ text: msg.text }); } }, [isStreaming, setQueue, stop], ); ``` В `onFinish` — добавить ветку слива при «нашем» abort'е (раньше там был `return`): ```ts // We triggered this abort via "Send now": flush the promoted head even though // the turn was aborted (the normal abort path keeps the queue intact). if (flushOnAbortRef.current) { flushOnAbortRef.current = false; // Suppress the "stopped" flash for an intentional interrupt+resend. setStopNotice(null); flushNext(); // interruptNextSendRef is consumed by the transport return; } if (isAbort || isDisconnect || isError) return; flushNext(); ``` В `prepareSendMessagesRequest` — read-and-clear флага, чтобы пометку нёс **только этот** запрос: ```ts prepareSendMessagesRequest: ({ messages, body }) => { const interrupted = interruptNextSendRef.current; interruptNextSendRef.current = false; // one-shot return { body: { ...body, chatId: chatIdRef.current, openPage: openPageRef.current, roleId: roleIdRef.current, interrupted, // server-side flag (see Server section) messages, }, }; }, ``` **C. Кнопка в списке очереди** — добавить `ActionIcon` рядом с крестиком: ```tsx <Tooltip label={t("Interrupt and send now")} withArrow> <ActionIcon size="xs" variant="subtle" color="blue" onClick={() => sendNow(m.id)} aria-label={t("Send now")}> <IconPlayerPlayFilled size={12} /> </ActionIcon> </Tooltip> ``` Строки `t("Send now")` / `t("Interrupt and send now")` — английский исходник как ключ (i18n здесь Crowdin-managed, отдельный JSON править не нужно). ### Сервер **A. `ai-chat.service.ts`:** - В `AiChatStreamBody` добавить `interrupted?: boolean`. - В `stream()` после загрузки `history` вычислить факт прерывания и **подтвердить его историей** (защита от ложного/подменённого флага): ```ts // The just-inserted user row is the tail; the turn before it is history[len-2]. // Treat the new turn as an interrupt-resume only when the client said so AND the // preceding assistant turn really ended unfinished ('aborted', or still // 'streaming' if onAbort has not finalized it yet — the abort/resend race). const prev = history[history.length - 2]; const interrupted = body.interrupted === true && prev?.role === 'assistant' && (prev.status === 'aborted' || prev.status === 'streaming'); ``` - Передать `interrupted` в `buildSystemPrompt({ ... , interrupted })`. **B. `ai-chat.prompt.ts`:** добавить поле в `BuildSystemPromptInput` и врезку в секцию `context` (внутри safety-сэндвича, перед завершающим `SAFETY_FRAMEWORK`): ```ts // Injected only on the turn that immediately follows a user interruption, so the // model treats the partial assistant message above as incomplete and continues // from the user's new instruction instead of assuming it had finished. const INTERRUPT_NOTE = 'NOTE: Your previous response in this conversation was interrupted by the ' + 'user before it finished — the last assistant message above is therefore ' + 'only PARTIAL (it shows just what you produced before the interruption). The ' + 'user has now sent a new message. Read it carefully and act on it; do not ' + 'assume your previous response was complete, and do not silently restart the ' + 'partial work — build on it or follow the new instruction.'; // ... if (interrupted) context += `\n${INTERRUPT_NOTE}`; ``` Частичный вывод модели подавать **не нужно отдельно** — он уже в истории (`findRecent` → `aborted`-строка с `metadata.parts`). `INTERRUPT_NOTE` — это и есть «комментарий, что пользователь прервал». ## Краевые случаи (учтены в дизайне) 1. **Гонка `onAbort` ↔ загрузка истории нового хода.** Новый POST может прийти раньше, чем `onAbort` финализирует строку в `aborted`. Не критично: благодаря #183 строка ассистента существует заранее (`streaming`) и обновляется по шагам, а `findRecent` статус не фильтрует — частичный вывод **всегда доступен**; в худшем случае не успеет попасть лишь последний фрагмент текущего шага (несколько токенов). Поэтому в условие включён и статус `streaming`. 2. **«Отправить сейчас», когда стрим уже закончился.** `isStreaming === false` → ветка else: просто шлём сообщение, без abort и без пометки. 3. **Остальные отложенные сообщения.** Остаются в очереди и сольются штатно после завершения нового хода (обычный `flushNext` на чистом `onFinish`). 4. **Двойные/частые клики.** `flushOnAbortRef`/`interruptNextSendRef` — однократные (read-and-clear); повторный клик до оседания хода переустановит голову очереди, лишнего слива не будет. 5. **Беспарные tool-call'ы прерванного хода.** Не попадают в `capturedSteps`, поэтому история остаётся валидной; завершённые беспарные вызовы уже закрываются `output-error` в `assistantParts`. 6. **Мигание «Response stopped.».** Для намеренного interrupt'а гасим `stopNotice` в ветке `flushOnAbortRef`, чтобы не моргало перед стартом нового хода. 7. **Безопасность пометки.** Флаг `interrupted` подтверждается серверной историей (предыдущий ход реально `aborted`/`streaming`), иначе игнорируется — подменённый флаг на обычном ходе ничего не инжектит. 8. **Язык.** `INTERRUPT_NOTE` — на английском (как весь системный промпт); модель отвечает на языке пользователя по уже имеющейся инструкции. 9. **a11y/i18n.** У кнопки `aria-label` + tooltip; строки через `t(...)`. ## Принятые решения и альтернативы - **Куда класть пометку?** Выбран **системный промпт** (по образцу `FINAL_STEP_INSTRUCTION`) — кросс-провайдерно безопасно (z.ai/OpenAI/Anthropic и т.д.), минимально инвазивно, само исчезает на следующем ходу (флаг ставится только для interrupt-хода). Альтернатива — врезать note префиксом в *модельное* представление user-сообщения (локальнее «по месту», но требует возни с формой `ModelMessage.content`). Обе эквивалентны по эффекту; рекомендуется первая. - **Триггер пометки — явный флаг, а не только авто-детект.** Можно было бы инжектить note всегда, когда предыдущий ход `aborted` (покрыло бы и «Stop → потом дописал»). Но это даёт ложные срабатывания на несвязанном сообщении много позже. Поэтому: **флаг от кнопки + подтверждение историей**. Это точно соответствует формулировке «прервал его сообщением». ## План реализации (декомпозиция) 1. `queue-helpers.ts`: `promoteToHead` + юнит-тесты в `queue-helpers.test.ts`. 2. `chat-thread.tsx`: рефы `flushOnAbortRef`/`interruptNextSendRef`, `sendNow`, ветка слива в `onFinish`, `interrupted` в `prepareSendMessagesRequest`, кнопка в списке очереди, проброс `sendNow`. 3. Сервер: `interrupted` в `AiChatStreamBody`; вычисление+подтверждение `interrupted` в `stream()`; проброс в `buildSystemPrompt`. 4. `ai-chat.prompt.ts`: `interrupted` в `BuildSystemPromptInput` + `INTERRUPT_NOTE`. 5. Тесты: `ai-chat.prompt.spec.ts` (note есть при `interrupted:true`, нет при `false`); `ai-chat.service.spec.ts` (флаг без aborted-предшественника → note не инжектится; с aborted/streaming → инжектится); опционально — клиентский тест на `promoteToHead` и формирование `body.interrupted`. ## Затронутые файлы - `apps/client/src/features/ai-chat/utils/queue-helpers.ts` (+ `.test.ts`) - `apps/client/src/features/ai-chat/components/chat-thread.tsx` - `apps/server/src/core/ai-chat/ai-chat.service.ts` - `apps/server/src/core/ai-chat/ai-chat.prompt.ts` (+ `.spec.ts`) - `apps/server/src/core/ai-chat/ai-chat.service.spec.ts` > Дизайн подготовлен агентом-архитектором; реализация — отдельной задачей (ветка от свежего `develop`).
vvzvlad added the feature label 2026-06-26 05:01:41 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#198