Skip to Content
ДизайнSession RecapДизайн Session Recap

Дизайн Session Recap

Краткое резюме (1–2 предложения) «на чём я остановился», которое появляется, когда пользователь возвращается к неактивной сессии: по запросу (/recap) или после того, как терминал потерял фокус (blur) более чем на 5 минут.

Обзор

Когда пользователь через несколько дней вызывает /resume для старой сессии, прокрутка страниц истории, чтобы вспомнить, чем он занимался и что должно быть дальше, становится серьёзным препятствием. Простая перезагрузка сообщений не решает эту проблему UX.

Цель — заранее показывать краткое резюме из 1–2 предложений при возвращении пользователя:

  • Задача высокого уровня (чем занимается) → следующий шаг (что делать дальше).
  • Визуально отличается от обычных ответов ассистента, чтобы его никогда не спутали с новым выводом модели.
  • Best-effort: ошибки должны обрабатываться молча и никогда не прерывать основной поток.

Триггеры

ТриггерУсловияРеализация
РучнойПользователь запускает /recaprecapCommand.ts вызывает тот же базовый сервис
АвтоматическийТерминал потерял фокус (протокол фокуса DECSET 1004) на ≥ 5 мин + возврат фокуса + поток в состоянии IdleuseAwaySummary.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.tsReact-хук для автоматического триггера
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:

  1. Оба тега присутствуют: берётся содержимое между <recap>...</recap> (предпочтительно).
  2. Только открывающий тег (например, maxOutputTokens обрезал закрывающий): берётся всё после открывающего тега.
  3. Тег полностью отсутствует: возвращается пустая строка → сервис возвращает null → UI ничего не рендерит.

Третий уровень работает по принципу «лучше пропустить, чем показать неверное» — вывод преамбулы рассуждений модели хуже, чем полное отсутствие резюме.

Параметры вызова

ПараметрЗначениеПричина
modelgetFastModel() ?? getModel()Для резюме не нужна флагманская модель
tools[]Однократный запрос, без использования инструментов
maxOutputTokens300Запас для 1–2 коротких предложений + теги
temperature0.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.showSessionRecapfalseТолько для автоматического триггера. Ручная /recap игнорирует эту настройку.
general.sessionRecapAwayThresholdMinutes5Минуты в фоне перед срабатыванием авто-резюме при возврате фокуса. Соответствует значению по умолчанию в 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_SUMMARYClaude Code использует её для поддержания работы функции при отключённой телеметрии; текущая модель телеметрии Qwen Code в этом не нуждается.
Авто-резюме после завершения /resumeЛогичное продолжение, но требует точки входа в useResumeCommand; выходит за рамки этого PR.
Last updated on