Дизайн Session Recap
Краткое резюме (1–2 предложения) «на чём я остановился», которое появляется, когда пользователь возвращается к неактивной сессии: по запросу (
/recap) или после того, как терминал потерял фокус (blur) более чем на 5 минут.
Обзор
Когда пользователь через несколько дней вызывает /resume для старой сессии, прокрутка страниц истории, чтобы вспомнить, чем он занимался и что должно быть дальше, становится серьёзным препятствием. Простая перезагрузка сообщений не решает эту проблему UX.
Цель — заранее показывать краткое резюме из 1–2 предложений при возвращении пользователя:
- Задача высокого уровня (чем занимается) → следующий шаг (что делать дальше).
- Визуально отличается от обычных ответов ассистента, чтобы его никогда не спутали с новым выводом модели.
- Best-effort: ошибки должны обрабатываться молча и никогда не прерывать основной поток.
Триггеры
| Триггер | Условия | Реализация |
|---|---|---|
| Ручной | Пользователь запускает /recap | recapCommand.ts вызывает тот же базовый сервис |
| Автоматический | Терминал потерял фокус (протокол фокуса DECSET 1004) на ≥ 5 мин + возврат фокуса + поток в состоянии Idle | useAwaySummary.ts — таймер 5 мин + слушатель событий useFocus |
Оба пути сводятся к одной функции — generateSessionRecap() — для гарантии идентичного поведения. Автоматический триггер управляется настройкой general.showSessionRecap (по умолчанию: выключено — явное включение, чтобы фоновые вызовы LLM никогда не добавлялись в счёт пользователя незаметно); ручная команда игнорирует эту настройку.
Архитектура
┌────────────────────────────────────────────────────────────────────────┐
│ AppContainer.tsx │
│ isFocused = useFocus() │
│ isIdle = streamingState === Idle │
│ │ │
│ ├─→ useAwaySummary({enabled, config, isFocused, isIdle, │
│ │ │ addItem}) │
│ │ └─→ 5 min blur timer + idle/dedupe gates │
│ │ │ │
│ │ ↓ │
│ └─→ recapCommand (slash) ─→ generateSessionRecap(config, signal) │
│ │ │
│ ↓ │
│ ┌─────────────────────────┐ │
│ │ packages/core/services/ │ │
│ │ sessionRecap.ts │ │
│ └─────────────────────────┘ │
│ │ │
│ ↓ │
│ GeminiClient.generateContent │
│ (fastModel + tools:[]) │
│ │
│ addItem({type: 'away_recap', text}) ─→ HistoryItemDisplay │
│ └─ AwayRecapMessage rendered inline like any other history │
│ item (※ + bold "recap: " + italic content, all dim); │
│ scrolls naturally with the conversation. Mirrors Claude │
│ Code's away_summary system message. │
└────────────────────────────────────────────────────────────────────────┘Файлы
| Файл | Ответственность |
|---|---|
packages/core/src/services/sessionRecap.ts | Однократный вызов LLM + фильтрация истории + извлечение тегов |
packages/cli/src/ui/hooks/useAwaySummary.ts | React-хук для автоматического триггера |
packages/cli/src/ui/commands/recapCommand.ts | Точка входа для ручной команды /recap |
packages/cli/src/ui/components/messages/StatusMessages.tsx | Рендерер AwayRecapMessage (※ + жирный recap: + курсивный текст, всё приглушено) |
packages/cli/src/ui/types.ts | Тип HistoryItemAwayRecap |
packages/cli/src/ui/components/HistoryItemDisplay.tsx | Направляет элементы истории away_recap в рендерер |
packages/cli/src/config/settingsSchema.ts | Настройки general.showSessionRecap + general.sessionRecapAwayThresholdMinutes |
Дизайн промпта
Системный промпт
generationConfig.systemInstruction заменяет системный промпт основного агента для этого единственного вызова, поэтому модель ведёт себя исключительно как генератор резюме, а не как ассистент по написанию кода.
Обратите внимание, что GeminiClient.generateContent() внутренне пропускает промпт через getCustomSystemPrompt(), которая добавляет память пользователя (QWEN.md / управляемая автопамять) в качестве суффикса. Итоговый системный промпт выглядит как промпт резюме + память пользователя — это полезный контекст проекта для резюме, а не утечка.
Пункты ниже соответствуют 1:1 RECAP_SYSTEM_PROMPT:
- Менее 40 слов, 1–2 простых предложения (без markdown / списков / заголовков). Для китайского языка лимит составляет примерно 80 символов.
- Первое предложение: задача высокого уровня. Затем: конкретный следующий шаг.
- Явный запрет: перечисление выполненных действий, цитирование вызовов инструментов, отчёты о статусе.
- Соответствовать основному языку разговора (английский или китайский).
- Обернуть вывод в
<recap>...</recap>; ничего за пределами тегов.
Структурированный вывод + извлечение
Модели предписывается оборачивать ответ в <recap>...</recap>:
<recap>Refactoring loopDetectionService.ts to address long-session OOM. Next step is to implement option B.</recap>Причина: некоторые модели (семейство GLM, модели рассуждений) пишут абзац «размышлений» перед финальным ответом. Возврат сырого текста приведёт к утечке этих рассуждений в UI.
Функция extractRecap() имеет три уровня fallback:
- Оба тега присутствуют: берётся содержимое между
<recap>...</recap>(предпочтительно). - Только открывающий тег (например,
maxOutputTokensобрезал закрывающий): берётся всё после открывающего тега. - Тег полностью отсутствует: возвращается пустая строка → сервис возвращает
null→ UI ничего не рендерит.
Третий уровень работает по принципу «лучше пропустить, чем показать неверное» — вывод преамбулы рассуждений модели хуже, чем полное отсутствие резюме.
Параметры вызова
| Параметр | Значение | Причина |
|---|---|---|
model | getFastModel() ?? getModel() | Для резюме не нужна флагманская модель |
tools | [] | Однократный запрос, без использования инструментов |
maxOutputTokens | 300 | Запас для 1–2 коротких предложений + теги |
temperature | 0.3 | Преимущественно детерминированный вывод с небольшой естественной вариативностью |
systemInstruction | Указанный выше промпт только для резюме | Заменяет определение роли основного агента |
Фильтрация истории
geminiClient.getChat().getHistory() возвращает Content[], который включает:
- текстовые сообщения
user/model - части
functionCallотmodel - части
functionResponseотuser(могут содержать полное содержимое файлов) - части
thoughtотmodel(part.thought/part.thoughtSignature, скрытые рассуждения модели)
filterToDialog() сохраняет только части user / model с непустым текстом, которые не являются рассуждениями. Две причины:
- Вызовы инструментов / ответы: один
functionResponseможет занимать 10K+ токенов. 30 таких сообщений погрузят LLM для резюме в нерелевантные детали, что приведёт к трате токенов и смещению фокуса резюме на технический шум вроде «вызван инструмент X для чтения файла Y». - Части рассуждений: содержат внутренние рассуждения модели. Их включение создаёт риск восприятия скрытой цепочки рассуждений как диалога и вывода их в тексте резюме.
После удаления пустых сообщений takeRecentDialog обрезает историю до последних 30 сообщений и отказывается начинать срез с незавершённого ответа модели/инструмента.
Параллелизм и граничные случаи
Машина состояний хука автоматического триггера
useAwaySummary хранит три рефа:
| Реф | Значение |
|---|---|
blurredAtRef | Время начала blur (не очищается до возврата фокуса) |
recapPendingRef | Выполняется ли вызов LLM в данный момент |
inFlightRef | Текущий активный AbortController |
Зависимости useEffect: [enabled, config, isFocused, isIdle, addItem, thresholdMs].
| Событие | Действие |
|---|---|
!enabled || !config | Прервать активный вызов + очистить inFlightRef + очистить blurredAtRef |
!isFocused и blurredAtRef === null | Установить blurredAtRef = Date.now() |
isFocused и blurredAtRef === null | Ранний возврат (нет цикла blur для обработки — первый рендер или сразу после сброса кратковременного blur) |
isFocused и длительность blur < 5 мин | Очистить blurredAtRef, ждать следующего цикла blur |
isFocused и blur ≥ 5 мин и recapPendingRef | Возврат (дедупликация) |
isFocused и blur ≥ 5 мин и !isIdle | Сохранить blurredAtRef и ждать завершения хода (isIdle в зависимостях, поэтому эффект сработает повторно после завершения стриминга) |
isFocused и blur ≥ 5 мин и shouldFireRecap возвращает false | Очистить blurredAtRef и вернуться — разговор недостаточно продвинулся с последнего резюме (требуется ≥ 2 хода пользователя, аналогично Claude Code) |
isFocused и все условия выполнены | Очистить blurredAtRef, установить recapPendingRef = true, создать AbortController, отправить запрос LLM |
Колбэк .then повторно проверяет isIdleRef.current: если пользователь начал новый ход, пока LLM выполнялся, запоздалое резюме отбрасывается, чтобы не вставлять его в середине хода.
Блок .finally очищает recapPendingRef и очищает inFlightRef только если inFlightRef.current === controller (чтобы не перезаписать более новый контроллер).
Второй useEffect прерывает активный контроллер при размонтировании.
Ограничения /recap
CommandContext.ui.isIdleRef предоставляет текущее состояние потока (по аналогии с существующим паттерном btwAbortControllerRef). В интерактивном режиме recapCommand отклоняет выполнение, если !isIdleRef.current или pendingItem !== null. Одного pendingItem недостаточно, так как обычный ответ модели выполняется при streamingState === Responding и pendingItem === null.
Конфигурация и выбор модели
Пользовательские параметры
| Настройка | По умолчанию | Примечания |
|---|---|---|
general.showSessionRecap | false | Только для автоматического триггера. Ручная /recap игнорирует эту настройку. |
general.sessionRecapAwayThresholdMinutes | 5 | Минуты в фоне перед срабатыванием авто-резюме при возврате фокуса. Соответствует значению по умолчанию в Claude Code. |
fastModel | не задано | Рекомендуется (например, qwen3-coder-flash) для быстрых и дешёвых резюме. |
Fallback модели
config.getFastModel() ?? config.getModel():
- У пользователя задан
fastModelи он валиден для текущего типа авторизации → используетсяfastModel. - В противном случае → fallback на основную модель сессии (работает, но дороже и медленнее).
Наблюдаемость (Observability)
createDebugLogger('SESSION_RECAP') выводит:
- пойманные исключения из пути резюме (
debugLogger.warn).
Все ошибки полностью прозрачны для пользователя — резюме является вспомогательной функцией и никогда не выбрасывает исключения в UI. Разработчики могут выполнить grep по тегу [SESSION_RECAP] в файле отладочного лога: по умолчанию записывается в ~/.qwen/debug/<sessionId>.txt (latest.txt — симлинк на текущую сессию); отключается через QWEN_DEBUG_LOG_FILE=0.
Вне области применения (Out of Scope)
| Элемент | Почему не включено |
|---|---|
Прогресс-индикатор UI для /recap (спиннер / pendingItem) | Ожидание 3–5 секунд допустимо; добавляет сложность. |
| Автоматизированные тесты | Сервис небольшой (~150 строк), сначала тестируется вручную end-to-end; юнит-тесты можно добавить в отдельном PR. |
| Локализованные промпты | Системный промпт предназначен для модели; английский язык — наиболее надёжная основа. Модель сама выбирает язык вывода на основе разговора. |
Переменная окружения QWEN_CODE_ENABLE_AWAY_SUMMARY | Claude Code использует её для поддержания работы функции при отключённой телеметрии; текущая модель телеметрии Qwen Code в этом не нуждается. |
Авто-резюме после завершения /resume | Логичное продолжение, но требует точки входа в useResumeCommand; выходит за рамки этого PR. |