Skip to Content
ДизайнSession RecapДизайн резюме сессии (Session Recap)

Дизайн резюме сессии (Session Recap)

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

Обзор

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

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

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

Триггеры

ТриггерУсловияРеализация
РучнойПользователь вводит /recaprecapCommand.ts вызывает тот же базовый сервис
АвтоТерминал потерял фокус (протокол фокуса DECSET 1004) на ≥ 5 мин + фокус вернулся + стрим IdleuseAwaySummary.ts — таймер размытия 5 мин + обработчик события useFocus
HTTP демонаУдалённый клиент вызывает POST /session/:id/recapserver.ts маршрут → bridge.generateSessionRecap (ext-method roundtrip) → acpAgent.ts вызывает generateSessionRecap(session.getConfig(), signal)

Все три пути сходятся в одной функции generateSessionRecap() в core/services/sessionRecap.ts, что гарантирует идентичное поведение. Авто-триггер управляется настройкой general.showSessionRecap (по умолчанию: выкл. — явное согласие, чтобы фоновые LLM-вызовы никогда не добавлялись незаметно в счёт пользователя); ручная команда и маршрут HTTP демона игнорируют эту настройку (вызывающий делает явный запрос).

Путь доступа через демон

Маршрут демона не имеет строгого шлюзования (зеркалирует подход /session/:id/prompt — резюме тратит токены, но не изменяет состояние). Тег возможности session_recap рекламирует маршрут на /capabilities.features. SDK-хелперы: DaemonClient.recapSession(sessionId, opts) и DaemonSessionClient.recap(opts). См. docs/developers/qwen-serve-protocol.md § POST /session/:id/recap за контрактом взаимодействия и обёрткой ошибки.

Отмена отсутствует в v1. Маршрут не слушает отключение HTTP-клиента, в bridge.generateSessionRecap не передаётся AbortSignal, и обработчик потомка ACP передаёт сигнал от никогда не прерываемого AbortController().signal в основной хелпер (кросс-процессная инфраструктура отмены ещё не реализована). Единственные ограничения — резервный таймаут моста SESSION_RECAP_TIMEOUT_MS (60 с) и гонка закрытия транспорта при смерти ACP-канала. Подключение AbortController на стороне HTTP в изоляции было бы косметическим — LLM-вызов на стороне потомка всё равно выполнится до конца, поэтому полная отмена (e2e) без кросс-процессного компонента невозможна. Для v1 это приемлемо, так как резюме короткое (однократный побочный запрос, maxOutputTokens: 300, обычно ~1–5 с). В будущем метод отмены на основе идентификатора запроса может обеспечить полную сквозную отмену, если/когда затраты на пропускную способность это оправдают.

Архитектура

┌────────────────────────────────────────────────────────────────────────┐ │ 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: + курсивный контент, всё dim)
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>Рефакторинг loopDetectionService.ts для устранения OOM в длительных сессиях. Следующий шаг — реализовать вариант B.</recap>

Почему: некоторые модели (семейство GLM, модели рассуждения) пишут «размышляющий» абзац перед финальным ответом. Возврат сырого текста привёл бы к утечке этих рассуждений в интерфейс.

extractRecap() имеет три уровня запасных вариантов:

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

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

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

ПараметрЗначениеПричина
modelgetFastModel() ?? getModel()Для резюме не нужна frontier-модель
tools[]Одноразовый запрос, без использования инструментов
maxOutputTokens300Запас для 1–2 коротких предложений + теги
temperature0.3Почти детерминированно, с небольшой естественной вариативностью
systemInstructionПромпт только для резюме вышеЗаменяет определение роли основного агента

Фильтрация истории

geminiClient.getChat().getHistory() возвращает Content[], который включает:

  • текстовые сообщения user / model
  • части functionCall модели (model)
  • части functionResponse пользователя (которые могут содержать полные содержимое файлов)
  • «мысленные» части модели (part.thought / part.thoughtSignature — скрытые рассуждения модели)

filterToDialog() оставляет только части user / model, которые имеют непустой текст и не являются мыслями. Две причины:

  • Вызовы инструментов / ответы: один functionResponse может быть 10K+ токенов. 30 таких сообщений утопят LLM резюме в нерелевантных деталях, тратя токены и смещая резюме в сторону шума реализации вроде «вызвал такой-то инструмент для чтения такого-то файла».
  • Мыслительные части: содержат внутренние рассуждения модели. Их включение рискует трактовать скрытую цепочку мыслей как диалог и показывать её в тексте резюме.

После удаления пустых сообщений takeRecentDialog обрезает до последних 30 сообщений и отказывается начинать срез на «висящем» ответе модели/инструмента.

Конкурентность и граничные случаи

Автомат состояний хука авто-триггера

useAwaySummary хранит три рефа:

RefЗначение
blurredAtRefВремя начала размытия (не очищается до возврата фокуса)
recapPendingRefВыполняется ли LLM-вызов в данный момент
inFlightRefТекущий выполняющийся AbortController

Зависимости useEffect: [enabled, config, isFocused, isIdle, addItem, thresholdMs].

СобытиеДействие
!enabled || !configПрервать выполняющийся вызов + очистить inFlightRef + очистить blurredAtRef
!isFocused и blurredAtRef === nullУстановить blurredAtRef = Date.now()
isFocused и blurredAtRef === nullДосрочный возврат (нет цикла размытия — первый рендер или сразу после сброса короткого размытия)
isFocused и длительность размытия < 5 минОчистить blurredAtRef, ждать следующего цикла размытия
isFocused и размытие ≥ 5 мин и recapPendingRefДосрочный возврат (дедупликация)
isFocused и размытие ≥ 5 мин и !isIdleСохранить blurredAtRef и ждать завершения оборота (isIdle есть в зависимостях, поэтому эффект перезапустится, когда стрим завершится)
isFocused и размытие ≥ 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 и null pendingItem.

Конфигурация и выбор модели

Пользовательские настройки

НастройкаПо умолчаниюОписание
general.showSessionRecapfalseТолько авто-триггер. Ручной /recap игнорирует это.
general.sessionRecapAwayThresholdMinutes5Минуты размытия до срабатывания авто-резюме при возврате фокуса. Совпадает с умолчанием Claude Code.
fastModelне заданРекомендуется (например, qwen3-coder-flash) для быстрых и дешёвых резюме.

Запасной вариант модели

config.getFastModel() ?? config.getModel():

  • У пользователя задан fastModel и он допустим для текущего типа аутентификации → использовать fastModel.
  • Иначе → использовать основную модель сессии (работает, но дороже и медленнее).

Наблюдаемость

createDebugLogger('SESSION_RECAP') выводит:

  • перехваченные исключения из пути резюме (debugLogger.warn).

Все ошибки полностью прозрачны для пользователя — резюме является вспомогательной функцией и никогда не выбрасывает исключения в UI. Разработчики могут искать тег [SESSION_RECAP] в файле отладочного лога: по умолчанию пишется в ~/.qwen/debug/<sessionId>.txt (символическая ссылка latest.txt указывает на текущую сессию); отключить через QWEN_DEBUG_LOG_FILE=0.

Вне рамок

ПунктПочему нет
Индикатор прогресса для /recap (спиннер / pendingItem)Ожидание 3–5 секунд терпимо; добавляет сложность.
Автоматические тестыСервис небольшой (~150 строк), протестирован end-to-end вручную сначала; модульные тесты могут появиться в отдельном PR.
Локализованные промптыСистемный промпт для модели; английский — наиболее надёжная основа. Модель выбирает язык вывода из разговора.
Переменная окружения QWEN_CODE_ENABLE_AWAY_SUMMARYClaude Code использует её, чтобы оставить фичу включённой при отключённой телеметрии; текущая модель телеметрии Qwen Code в этом не нуждается.
Авто-резюме после завершения /resumeЕстественное продолжение, но требует точки перехвата в useResumeCommand; вне рамок данного PR.
Last updated on