feat(ai-chat): load full transcript for model history (drop 50-msg window) #202

Merged
vvzvlad merged 2 commits from feat/ai-chat-full-history into develop 2026-06-26 20:55:51 +03:00
Owner

Что и зачем

Убирает оконный лимит в 50 сообщений на историю, которую AI-агент получает каждый ход.

Раньше streamChat восстанавливал диалог через findRecent(chatId, ws, 50) — скользящее окно из 50 последних строк, из-за чего у чатов длиннее ~50 сообщений начало диалога выпадало из контекста модели.

Изменение

  • streamChat теперь грузит весь неудалённый транскрипт через уже существующий findAllByChat (хронологически, oldest → newest).
  • Сохранён защитный потолок в 5000 строк: это memory-safety backstop (на overflow держит новейшие строки + warning в лог), а не лимит диалога — далеко выше любого реального чата.
  • Удалён ставший мёртвым метод findRecent; обновлены комментарии и текст warning-лога (findAllByChat теперь обслуживает и Markdown-экспорт, и историю модели).

Проверки

  • Форма/порядок данных идентичны прежнему пути (AiChatMessage[], oldest → newest, те же baseFields без tsv).
  • Ссылок на findRecent не осталось; type-check чистый; логика тестов не тронута.

🤖 Generated with Claude Code

## Что и зачем Убирает оконный лимит в 50 сообщений на историю, которую AI-агент получает каждый ход. Раньше `streamChat` восстанавливал диалог через `findRecent(chatId, ws, 50)` — скользящее окно из 50 последних строк, из-за чего у чатов длиннее ~50 сообщений **начало диалога выпадало из контекста модели**. ## Изменение - `streamChat` теперь грузит **весь** неудалённый транскрипт через уже существующий `findAllByChat` (хронологически, oldest → newest). - Сохранён защитный потолок в 5000 строк: это memory-safety backstop (на overflow держит новейшие строки + warning в лог), а не лимит диалога — далеко выше любого реального чата. - Удалён ставший мёртвым метод `findRecent`; обновлены комментарии и текст warning-лога (`findAllByChat` теперь обслуживает и Markdown-экспорт, и историю модели). ## Проверки - Форма/порядок данных идентичны прежнему пути (`AiChatMessage[]`, oldest → newest, те же `baseFields` без `tsv`). - Ссылок на `findRecent` не осталось; type-check чистый; логика тестов не тронута. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
vvzvlad added 2 commits 2026-06-25 23:55:18 +03:00
Removed six outdated markdown files from the `docs/backlog` and other docs directories that were no longer relevant to the project. This cleans up the repository and reduces clutter.
The per-turn model conversation was rebuilt via findRecent(chatId, ws, 50),
a sliding window that dropped the beginning of any chat longer than ~50 stored
rows. Switch streamChat to the existing findAllByChat, which loads the full
non-deleted transcript chronologically with a 5000-row memory-safety backstop
(keeps the newest rows + logs a warning on overflow) — a safety net, not a
conversational limit. Remove the now-unused findRecent method and update the
comments/log text that referenced it (findAllByChat now feeds both the Markdown
export and the model history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Code review — PR #202: загрузка полного транскрипта в историю модели (отказ от окна 50 сообщений)

Вердикт: Approve with comments (одобрить с замечаниями).

Изменение корректное и аккуратное: это like-for-like замена findRecent на уже существующий findAllByChat. Воркспейс-скоупинг (workspaceId) и фильтр soft-delete сохранены, набор колонок тот же (без tsv), мёртвый findRecent удалён без единой висячей ссылки, комментарии переписаны точно. Блокирующих и критических находок нет. Единственное содержательное замечание (не блокер): сняв окно в 50 сообщений, изменение делает достижимым переполнение контекстного окна модели на длинных чатах — стоит осознанно решить, нужен ли токен-бюджет (см. раздел Architecture).

  • [warning][stability] Рассмотреть токен-бюджетный трим истории перед streamText, иначе длинный чат может стать неработоспособнымapps/server/src/core/ai-chat/ai-chat.service.ts:323-336.
    Путь findAllByChathistory.map(rowToUiMessage)await convertToModelMessagesstreamText({ messages }) не содержит никакого контроля токенов на входе: есть только компакция отдельных tool-output (compactToolOutput) и пост-фактум contextTokens из usage в onFinish; maxOutputTokens намеренно не задан и ограничивает лишь вывод. Раньше окно findRecent(…, 50) держало вход небольшим. Теперь, как только суммарный транскрипт превысит контекстное окно модели, провайдер начнёт отклонять запрос (context_length_exceeded) на каждом ходу: onError лишь фиксирует ход как 'error' и не делает ретрай с тримом, а следующий ход снова грузит весь (уже больший) транскрипт — чат остаётся неработоспособным до создания нового. На клиенте это уже отлавливается (apps/client/.../error-message.ts показывает «…exceeded the model's context window. Start a new chat…»), то есть деградация управляемая — «начни новый чат», а не тихий краш. Колпак в 5000 строк — защита по памяти, а не по токенам, и срабатывает гораздо позже реального лимита окна. Это осознанный компромисс заявленной цели PR (модель видит весь транскрипт), поэтому не блокер.
    Fix: перед streamText обрезать messages под входной бюджет модели (с самого старого конца),

  • [suggestion][stability/perf] Учитывать, что пересборка истории теперь стоит O(N) на каждый ходapps/server/src/core/ai-chat/ai-chat.service.ts:330-336.
    Каждый ход перезапрашивает все непустые строки чата (до 5000), маппит rowToUiMessage и делает await convertToModelMessages по всему набору; раньше это было ограничено 50. Стоимость растёт линейно с длиной чата (включая крупные jsonb metadata.parts). На реальных длинных чатах это измеримая, но вторичная по сравнению с латентностью модели нагрузка. Fix: действий не требуется, если латентность приемлема; иначе — кэшировать сконвертированную историю и дописывать только новые ходы, или ограничить загрузку токен/строчным бюджетом (то же решение, что и выше).

Test coverage

Поведение, на которое опирается новый путь, уже покрыто: findAllByChat — тот же метод, что использует экспорт, и его контракт (хронологический порядок oldest→newest, кап «оставить новейшие» с разворотом, вызов с дефолтным лимитом) закреплён интеграционными тестами apps/server/test/integration/ai-chat-message-status.int-spec.ts:235-269. Новых веток внутри findAllByChat путь истории не вводит. Удалённый findRecent на merge-base собственных тестов не имел — его удаление не теряет покрытия.

  • [suggestion][test-coverage] Само переключение источника истории в handle() не закреплено тестомapps/server/src/core/ai-chat/ai-chat.service.ts:330-333. Симметричный экспорт-путь пинуется (ai-chat.controller.export.spec.ts:66 проверяет точный вызов findAllByChat('c1','ws1')), а у handle() такой проверки нет, и сам метод не покрыт ни unit-, ни интеграционными тестами. Это лишь suggestion, потому что handle() — стриминговый метод, который проект сам помечает как «not unit-testable» (ai-chat.service.spec.ts:500) и по соглашению не тестирует, а нижележащее поведение уже покрыто. Fix (опционально): интеграционный тест, засевающий чат >50 сообщений и проверяющий, что пересобранная история отражает все ходы.
## Code review — PR #202: загрузка полного транскрипта в историю модели (отказ от окна 50 сообщений) **Вердикт: Approve with comments** (одобрить с замечаниями). Изменение корректное и аккуратное: это like-for-like замена `findRecent` на уже существующий `findAllByChat`. Воркспейс-скоупинг (`workspaceId`) и фильтр soft-delete сохранены, набор колонок тот же (без `tsv`), мёртвый `findRecent` удалён без единой висячей ссылки, комментарии переписаны точно. **Блокирующих и критических находок нет.** Единственное содержательное замечание (не блокер): сняв окно в 50 сообщений, изменение делает достижимым переполнение контекстного окна модели на длинных чатах — стоит осознанно решить, нужен ли токен-бюджет (см. раздел Architecture). - **[warning][stability] Рассмотреть токен-бюджетный трим истории перед `streamText`, иначе длинный чат может стать неработоспособным** — `apps/server/src/core/ai-chat/ai-chat.service.ts:323-336`. Путь `findAllByChat` → `history.map(rowToUiMessage)` → `await convertToModelMessages` → `streamText({ messages })` не содержит никакого контроля токенов на входе: есть только компакция отдельных tool-output (`compactToolOutput`) и пост-фактум `contextTokens` из usage в `onFinish`; `maxOutputTokens` намеренно не задан и ограничивает лишь вывод. Раньше окно `findRecent(…, 50)` держало вход небольшим. Теперь, как только суммарный транскрипт превысит контекстное окно модели, провайдер начнёт отклонять запрос (`context_length_exceeded`) на **каждом** ходу: `onError` лишь фиксирует ход как `'error'` и не делает ретрай с тримом, а следующий ход снова грузит весь (уже больший) транскрипт — чат остаётся неработоспособным до создания нового. На клиенте это уже отлавливается (`apps/client/.../error-message.ts` показывает «…exceeded the model's context window. Start a new chat…»), то есть деградация управляемая — «начни новый чат», а не тихий краш. Колпак в 5000 строк — защита по памяти, а не по токенам, и срабатывает гораздо позже реального лимита окна. Это осознанный компромисс заявленной цели PR (модель видит весь транскрипт), поэтому **не блокер**. Fix: перед `streamText` обрезать `messages` под входной бюджет модели (с самого старого конца), - **[suggestion][stability/perf] Учитывать, что пересборка истории теперь стоит O(N) на каждый ход** — `apps/server/src/core/ai-chat/ai-chat.service.ts:330-336`. Каждый ход перезапрашивает все непустые строки чата (до 5000), маппит `rowToUiMessage` и делает `await convertToModelMessages` по всему набору; раньше это было ограничено 50. Стоимость растёт линейно с длиной чата (включая крупные jsonb `metadata.parts`). На реальных длинных чатах это измеримая, но вторичная по сравнению с латентностью модели нагрузка. Fix: действий не требуется, если латентность приемлема; иначе — кэшировать сконвертированную историю и дописывать только новые ходы, или ограничить загрузку токен/строчным бюджетом (то же решение, что и выше). ### Test coverage Поведение, на которое опирается новый путь, **уже покрыто**: `findAllByChat` — тот же метод, что использует экспорт, и его контракт (хронологический порядок oldest→newest, кап «оставить новейшие» с разворотом, вызов с дефолтным лимитом) закреплён интеграционными тестами `apps/server/test/integration/ai-chat-message-status.int-spec.ts:235-269`. Новых веток внутри `findAllByChat` путь истории не вводит. Удалённый `findRecent` на merge-base собственных тестов не имел — его удаление не теряет покрытия. - **[suggestion][test-coverage] Само переключение источника истории в `handle()` не закреплено тестом** — `apps/server/src/core/ai-chat/ai-chat.service.ts:330-333`. Симметричный экспорт-путь пинуется (`ai-chat.controller.export.spec.ts:66` проверяет точный вызов `findAllByChat('c1','ws1')`), а у `handle()` такой проверки нет, и сам метод не покрыт ни unit-, ни интеграционными тестами. Это лишь suggestion, потому что `handle()` — стриминговый метод, который проект сам помечает как «not unit-testable» (`ai-chat.service.spec.ts:500`) и по соглашению не тестирует, а нижележащее поведение уже покрыто. Fix (опционально): интеграционный тест, засевающий чат >50 сообщений и проверяющий, что пересобранная история отражает **все** ходы.
vvzvlad added the feature label 2026-06-26 00:31:18 +03:00
Ghost added 1 commit 2026-06-26 17:19:32 +03:00
Address the PR #202 review (approve-with-comments). The only actionable
non-blocking item was the test-coverage suggestion: the source switch in
AiChatService.handle from findRecent(chatId, ws, 50) to findAllByChat(chatId,
ws) was not pinned by a test. handle() is a streaming method the project marks
as not unit-testable, so cover the behavioral guarantee it now relies on at the
repo/integration level — seed a chat of 60 messages and assert the default
findAllByChat (exactly how handle calls it) returns the FULL transcript in
chronological order, including the first turn the old 50-window would have
dropped.

Also document the behavior change under CHANGELOG [Unreleased] -> Changed.

The two stability items (token-budget trim before streamText; O(N) history
rebuild per turn) are deferred: the reviewer flagged both as non-blocking
conscious trade-offs aligned with the PR's stated goal, and the trim is a
larger architecture change out of scope for this follow-up.

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

Code review (re-review) — PR #202: загрузка полного транскрипта в историю модели (отказ от окна 50 сообщений)

Вердикт: Approve. Прошлое непокрытое замечание (переполнение контекста модели на длинном чате) осознанно отложено и задокументировано в CHANGELOG, а новый интеграционный тест закрепляет ключевую гарантию перехода с окна на полный транскрипт. Дельта корректна, новых блокеров нет.

Ре-ревью дельты 286ab775..4ff5f86d (2 файлов, +41/−0). Аспекты: stability, conventions, documentation, test-coverage (параллельные ревьюеры + judge).

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

  • Прошлый блокер (Approve with comments): риск переполнения окна контекста модели после удаления окна 50 сообщений — token-budget trim перед streamText. Закрыт по существу как осознанный defer, а не косметически. Дельта не вводит token-budget trim, но явно документирует поведение в CHANGELOG.md: на очень длинном чате это «can eventually reach the model's context window; the client already surfaces that as "start a new chat"». Память процесса защищена бэкстопом FIND_ALL_BY_CHAT_LIMIT = 5000 в findAllByChat (при переполнении сохраняются новейшие строки + warning) — это явно описано как safety net, а не разговорный лимит. Решение «отложить trim» обосновано и зафиксировано.

Must fix before merge

Нет.

Non-blocking

Нет.

Test coverage

Покрыто. Новый тест default findAllByChat returns the FULL transcript past 50 rows (apps/server/test/integration/ai-chat-message-status.int-spec.ts:271) закрепляет поведенческую гарантию, на которую опирается переход в ai-chat.service.ts:330 (findAllByChat(chatId, workspaceId) без аргумента limit → дефолт 5000): чат из 60 строк возвращается целиком в хронологическом порядке (oldest→newest), первая реплика msg-0, которую старое окно 50 отбросило бы, присутствует. Тест использует те же хелперы (createChat, createMessage с createdAt) и тот же вызов API, что и существующие тесты файла; дефолт limit и chronological-order поведение совпадают с реальной реализацией репозитория. Бэкстоп-усечение (limit=2/100) уже покрыто соседним тестом #183 review.

## Code review (re-review) — PR #202: загрузка полного транскрипта в историю модели (отказ от окна 50 сообщений) **Вердикт: Approve.** Прошлое непокрытое замечание (переполнение контекста модели на длинном чате) осознанно отложено и задокументировано в CHANGELOG, а новый интеграционный тест закрепляет ключевую гарантию перехода с окна на полный транскрипт. Дельта корректна, новых блокеров нет. _Ре-ревью дельты `286ab775..4ff5f86d` (2 файлов, +41/−0). Аспекты: stability, conventions, documentation, test-coverage (параллельные ревьюеры + judge)._ ### Статус прошлых блокеров - **Прошлый блокер (Approve with comments): риск переполнения окна контекста модели после удаления окна 50 сообщений — token-budget trim перед `streamText`.** Закрыт по существу как осознанный defer, а не косметически. Дельта не вводит token-budget trim, но явно документирует поведение в `CHANGELOG.md`: на очень длинном чате это «can eventually reach the model's context window; the client already surfaces that as "start a new chat"». Память процесса защищена бэкстопом `FIND_ALL_BY_CHAT_LIMIT = 5000` в `findAllByChat` (при переполнении сохраняются новейшие строки + warning) — это явно описано как safety net, а не разговорный лимит. Решение «отложить trim» обосновано и зафиксировано. ### Must fix before merge Нет. ### Non-blocking Нет. ### Test coverage Покрыто. Новый тест `default findAllByChat returns the FULL transcript past 50 rows` (`apps/server/test/integration/ai-chat-message-status.int-spec.ts:271`) закрепляет поведенческую гарантию, на которую опирается переход в `ai-chat.service.ts:330` (`findAllByChat(chatId, workspaceId)` без аргумента limit → дефолт 5000): чат из 60 строк возвращается целиком в хронологическом порядке (oldest→newest), первая реплика `msg-0`, которую старое окно 50 отбросило бы, присутствует. Тест использует те же хелперы (`createChat`, `createMessage` с `createdAt`) и тот же вызов API, что и существующие тесты файла; дефолт limit и chronological-order поведение совпадают с реальной реализацией репозитория. Бэкстоп-усечение (limit=2/100) уже покрыто соседним тестом `#183 review`.
Ghost force-pushed feat/ai-chat-full-history from 4ff5f86d99 to e99c00a9ee 2026-06-26 20:41:37 +03:00 Compare
vvzvlad merged commit 719bccd80d into develop 2026-06-26 20:55:51 +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#202