Дизайн резюме сессии (Session Recap)
Краткое (1–2 предложения) резюме «на чём я остановился», которое показывается пользователю при возвращении к бездействующей сессии — либо по запросу (
/recap), либо после того, как терминал был в фокусе более 5 минут.
Обзор
Когда пользователь возвращается к старой сессии через /resume спустя дни, пролистывание страниц истории,
чтобы вспомнить, чем он занимался и что делать дальше, становится настоящей проблемой UX.
Простая перезагрузка сообщений эту проблему не решает.
Цель — проактивно показывать краткое резюме (1–2 предложения) при возвращении пользователя:
- Высокоуровневая задача (что делается) → следующий шаг (что делать дальше).
- Визуально отличное от реальных ответов ассистента, чтобы его никогда не приняли за новый вывод модели.
- Best-effort: ошибки должны быть тихими и никогда не нарушать основной поток.
Триггеры
| Триггер | Условия | Реализация |
|---|---|---|
| Ручной | Пользователь вводит /recap | recapCommand.ts вызывает тот же базовый сервис |
| Авто | Терминал потерял фокус (протокол фокуса DECSET 1004) на ≥ 5 мин + фокус вернулся + стрим Idle | useAwaySummary.ts — таймер размытия 5 мин + обработчик события useFocus |
| HTTP демона | Удалённый клиент вызывает POST /session/:id/recap | server.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.ts | React-хук авто-триггера |
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() имеет три уровня запасных вариантов:
- Оба тега присутствуют: берётся содержимое между
<recap>...</recap>(предпочтительно). - Только открывающий тег (например,
maxOutputTokensобрезал закрывающий тег): берётся всё после открывающего тега. - Тег отсутствует полностью: возвращается пустая строка → сервис возвращает
null→ UI ничего не рендерит.
Третий уровень — «пропустить вместо отображения неверного»: показывать преамбулу рассуждений модели хуже, чем вообще не показывать резюме.
Параметры вызова
| Параметр | Значение | Причина |
|---|---|---|
model | getFastModel() ?? getModel() | Для резюме не нужна frontier-модель |
tools | [] | Одноразовый запрос, без использования инструментов |
maxOutputTokens | 300 | Запас для 1–2 коротких предложений + теги |
temperature | 0.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.showSessionRecap | false | Только авто-триггер. Ручной /recap игнорирует это. |
general.sessionRecapAwayThresholdMinutes | 5 | Минуты размытия до срабатывания авто-резюме при возврате фокуса. Совпадает с умолчанием 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_SUMMARY | Claude Code использует её, чтобы оставить фичу включённой при отключённой телеметрии; текущая модель телеметрии Qwen Code в этом не нуждается. |
Авто-резюме после завершения /resume | Естественное продолжение, но требует точки перехвата в useResumeCommand; вне рамок данного PR. |