Проектирование заголовков сессий
Заголовок сессии из 3–7 слов в sentence case, генерируемый быстрой моделью после первого ответа ассистента. Сохраняется в JSONL сессии с тегом
titleSource: 'auto' | 'manual', отображается в селекторе сессий и может быть перегенерирован по запросу через/rename --auto.
Обзор
/rename (#3093) позволяет пользователю задать метку сессии, чтобы позже найти её в
селекторе. Однако до её выполнения селектор показывает первый промпт пользователя — часто обрезанный на полуслове или описывающий вводный вопрос, а не то, чем сессия стала по факту. Ручное переименование создаёт необязательное трение, которое большинство пользователей игнорирует.
Цель — сделать имена сессий полезными по умолчанию:
- Описательные: отражают то, что сессия фактически выполнила, а не просто первую строку. 3–7 слов, sentence case, в стиле темы git-коммита.
- Best-effort: запускается в фоне после первого ответа; в случае ошибки пользователь её не увидит.
- Уважение к выбору пользователя: никогда не перезаписывает заголовок, заданный вручную через
/rename, даже при работе с одной сессией в разных вкладках CLI. - Явная перегенерация через
/rename --autoдля случаев, когда автозаголовок устарел или нужен новый.
Триггеры
| Триггер | Условия | Реализация |
|---|---|---|
| Auto | После срабатывания recordAssistantTurn. Пропускается, если заголовок уже установлен, другая попытка выполняется, достигнут лимит, режим non-interactive, отключено через env или нет быстрой модели. | ChatRecordingService.maybeTriggerAutoTitle — fire-and-forget |
| Manual | Пользователь запускает /rename --auto | renameCommand.ts через tryGenerateSessionTitle |
Оба пути сводятся к одной функции — tryGenerateSessionTitle(config, signal) — что гарантирует идентичный промпт, схему, выбор модели и
санитизацию. Авто-триггер — это фоновый вызов best-effort;
ручной /rename --auto — блокирующее действие пользователя, которое в случае
ошибки выводит сообщение с конкретной причиной.
Архитектура
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/core/src/services/ │
│ │
│ ┌──────────────────────────┐ │
│ │ chatRecordingService.ts │ │
│ │ │ │
│ │ recordAssistantTurn() │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ maybeTriggerAutoTitle() │── 6 guards ──→ IIFE(autoTitleController) │
│ │ │ │ │ │
│ │ └── resume hydrate │ ↓ │
│ │ via │ tryGenerateSessionTitle │
│ │ getSessionTitle- │ (sessionTitle.ts) │
│ │ Info │ │ │
│ │ │ ↓ │
│ └──────────────────────────┘ BaseLlmClient.generateJson │
│ (fastModel + JSON schema) │
│ │ │
│ ┌──────────────────────────┐ ↓ │
│ │ sessionService.ts │ sanitizeTitle + sanity checks │
│ │ │ │ │
│ │ getSessionTitleInfo() │◀── cross-process ↓ │
│ │ uses │ re-read recordCustomTitle │
│ │ readLastJsonString- │ before write (…, 'auto') │
│ │ FieldsSync │ │
│ │ (sessionStorageUtils) │ │
│ └──────────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ utils/terminalSafe │ │
│ │ stripTerminalCtrl- │ │
│ │ Sequences │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/cli/src/ui/ │
│ │
│ commands/renameCommand.ts ─── /rename <name> → manual │
│ ─── /rename → kebab │
│ ─── /rename --auto → auto │
│ ─── /rename -- --literal → manual │
│ ─── /rename --unknown-flag → error │
│ │
│ components/SessionPicker.tsx ── dims rows where │
│ session.titleSource === 'auto' │
└─────────────────────────────────────────────────────────────────────────┘Файлы
| Файл | Ответственность |
|---|---|
packages/core/src/services/sessionTitle.ts | Однократный вызов LLM + фильтрация истории + санитизация. Экспортирует tryGenerateSessionTitle. |
packages/core/src/services/chatRecordingService.ts | Триггер maybeTriggerAutoTitle, проверки (guards), повторное чтение между процессами, отмена при finalize. |
packages/core/src/services/sessionService.ts | Публичный аксессор getSessionTitleInfo; renameSession принимает titleSource. |
packages/core/src/utils/sessionStorageUtils.ts | Атомарное чтение пары extractLastJsonStringFields + readLastJsonStringFieldsSync. |
packages/core/src/utils/terminalSafe.ts | stripTerminalControlSequences, используется в путях sentence-case и kebab. |
packages/cli/src/ui/commands/renameCommand.ts | /rename --auto, парсер сентинелов, маппинг сообщений об ошибках. |
packages/cli/src/ui/components/SessionPicker.tsx | Стилизация затемнения для titleSource === 'auto'. |
Проектирование промпта
Системный промпт
Заменяет системный промпт основного агента для этого единственного вызова, чтобы модель только присваивала метку сессии, а не вела себя как ассистент для кодинга.
Пункты ниже соответствуют 1:1 с TITLE_SYSTEM_PROMPT:
- 3–7 слов, sentence case (с заглавной буквы только первое слово и имена собственные).
- Без знаков препинания в конце, без markdown, без кавычек.
- Соответствовать основному языку разговора; для китайского — примерно 12–20 символов.
- Конкретно указывать реальную цель пользователя — называть фичу, баг или предметную область. Избегать размытых формулировок вроде “Code changes” или “Help request”.
- Четыре хороших примера (три на английском + один на китайском) и четыре плохих (слишком размытые / слишком длинные / неверный регистр / знаки препинания в конце).
- Возвращать только JSON-объект с единственным ключом
title.
Структурированный вывод (JSON schema)
Вместо оборачивания вывода в теги (как в session-recap) используется
BaseLlmClient.generateJson со схемой function-calling:
const TITLE_SCHEMA = {
type: 'object',
properties: {
title: {
type: 'string',
description:
'A concise sentence-case session title, 3-7 words, no trailing punctuation.',
},
},
required: ['title'],
};Почему function calling, а не свободный текст + извлечение тегов:
- Надёжность между провайдерами — OpenAI-совместимые эндпоинты, Gemini и нативный tool-calling Qwen поддерживают function calling; парсинг тегов зависел бы от того, соблюдает ли каждая модель текстовое соглашение.
- Отсутствие утечки преамбулы рассуждений — аргументы вызова функции возвращаются структурированно, поэтому абзац “thinking” перед ответом не попадёт в заголовок.
- Упрощённая постобработка — одной проверки
typeof result.title === 'string'плюсsanitizeTitleдостаточно для покрытия любого реалистичного дрейфа модели.
Модель всё ещё может вернуть что-то, что разрешено схемой, но UX
отвергает (пустая строка, только пробелы, 500 символов, markdown-ограждения,
управляющие символы). sanitizeTitle обрабатывает всё это и возвращает '' →
сервис возвращает {ok: false, reason: 'empty_result'}.
Параметры вызова
| Параметр | Значение | Причина |
|---|---|---|
model | getFastModel() — без fallback | Авто-титлинг на токенах основной модели слишком дорог для незаметного выполнения. |
schema | TITLE_SCHEMA | Принудительно задаёт {title: string}; отфильтровывает дрейф структуры на транспортном уровне. |
maxOutputTokens | 100 | С запасом хватает на 7 слов плюс накладные расходы схемы. |
temperature | 0.2 | Преимущественно детерминировано — заголовкам сессий полезна стабильность при перегенерации. |
maxAttempts | 1 | Заголовки — это косметические метаданные best-effort; повторные попытки встали бы в очередь за основным трафиком, видимым пользователю. |
В отличие от session-recap, который использует fallback на основную модель.
Генерация заголовков срабатывает автоматически и часто; незаметная трата
токенов основной модели без явного согласия пользователя — это реальный
сюрприз в счёте. Ручной
/rename --auto явно завершается ошибкой no_fast_model вместо
fallback — заставляя пользователя осознанно выбрать быструю модель.
Фильтрация истории
geminiClient.getChat().getHistory() возвращает Content[], который включает
вызовы инструментов, ответы инструментов (часто 10K+ токенов содержимого файлов) и части рассуждений модели. Подача этого в сыром виде в LLM для заголовков сместит метку
в сторону шума реализации, например “Called grep on auth module”.
filterToDialog оставляет только записи user / model с непустым текстом
и без частей thought / thoughtSignature. takeRecentDialog обрезает до
последних 20 сообщений и отказывается начинаться с оборванного ответа модели/инструмента. flattenToTail преобразует данные в строки “Role: text” и обрезает
последние 1000 символов.
Обрезка хвоста до 1000 символов
Сессия, начинающаяся с help me debug X, но переходящая к рефакторингу Y,
должна получать заголовок про Y. Титлинг по началу фиксирует вводный контекст; титлинг по хвосту отражает то, чем сессия стала по факту.
Обработка суррогатных пар UTF-16
.slice(-1000) на границе кодовой единицы UTF-16 может оставить одинокий высокий или низкий суррогат, если будет обрезан дополнительный символ CJK или эмодзи. Некоторые провайдеры
отвечают на полученный невалидный UTF-16 ошибкой 400 — что без обработки сожжёт попытку впустую. flattenToTail отбрасывает ведущий одинокий низкий суррогат; sanitizeTitle также очищает любые одинокие суррогаты после обрезки по максимальной длине на пути вывода.
Сохранение
Структура записи
CustomTitleRecordPayload получает опциональное поле titleSource: 'auto' | 'manual':
{
"type": "system",
"subtype": "custom_title",
"systemPayload": {
"customTitle": "Debug login button on mobile",
"titleSource": "auto",
},
}Поле опционально, а его отсутствие в старых записях трактуется как
undefined. SessionPicker затемняет строки только при строгом совпадении === 'auto'
— заголовок, заданный пользователем через /rename до изменений, никогда не будет молча переклассифицирован как предположение модели.
Гидратация при возобновлении
При возобновлении конструктор ChatRecordingService вызывает
sessionService.getSessionTitleInfo(sessionId) для чтения как заголовка, так и его источника. Без восстановления источника повторная запись finalize() (выполняемая при каждом событии жизненного цикла сессии) перезаписывала бы auto как manual на каждом цикле возобновления — молча убирая визуальный индикатор затемнения.
Атомарное чтение пары
extractLastJsonStringFields возвращает customTitle и titleSource
из одной и той же совпавшей строки за один проход. Два отдельных
вызова readLastJsonStringFieldSync могли бы попасть на разные записи, если
в старой строке есть только основное поле, что даст несогласованную пару.
Экстрактор также требует корректную закрывающую кавычку у основного значения,
поэтому аварийно обрезанная конечная запись не выиграет гонку за последнее совпадение.
Лимит полного сканирования файла
Фаза 2 (когда быстрый путь по окну хвоста не срабатывает) стримит весь файл чанками по 64 КБ. Лимит установлен на MAX_FULL_SCAN_BYTES = 64 MB, чтобы повреждённый JSONL на несколько гигабайт не заморозил селектор сессий в основном event loop.
Бюджет задержек селектора остаётся в норме даже при повреждении.
Защита от симлинков
Чтение сессий открывается с флагом O_NOFOLLOW (на Windows, где константа не экспортируется, используется обычный read-only). Многоуровневая защита гарантирует, что симлинк, подброшенный в ~/.qwen/projects/<proj>/chats/, не перенаправит чтение метаданных на сторонний файл.
Конкурентность и граничные случаи
Порядок проверок триггера
maybeTriggerAutoTitle проверяет шесть условий в строгом порядке — каждое
прерывает выполнение остальных, чтобы сначала выполнялись дешёвые проверки:
currentCustomTitleустановлен → пропуск. Никогда не перезаписывать ручной или предыдущий авто-заголовок.autoTitleController !== undefined→ пропуск. Только одна попытка за раз.autoTitleAttempts >= 3→ пропуск. Лимит ограничивает общие потери.!config.isInteractive()→ пропуск. Headlessqwen -p/ CI никогда не тратит токены быстрой модели на одноразовую сессию.autoTitleDisabledByEnv()→ пропуск. Явный отказ черезQWEN_DISABLE_AUTO_TITLE=1.!config.getFastModel()→ пропуск. Нет быстрой модели → no-op.
Почему лимит равен 3, а не 1
Первый ход ассистента может быть чистым вызовом инструмента без видимого пользователю текста (например, модель начинает с grep). tryGenerateSessionTitle
возвращает {ok: false, reason: 'empty_history'} в этом случае. Без окна повторных попыток шанс сессии получить заголовок сгорел бы на первом ходу, до того как пользователь скажет что-то интересное. Лимит в 3 покрывает частый случай “первый ход — шум”, но при этом ограничивает бесконечные повторы при стабильно падающей быстрой модели.
Гонка ручного переименования между процессами
Две вкладки CLI, работающие с одним файлом сессии, могут рассинхронизироваться в памяти. Вкладка A запускает /rename foo и записывает titleSource: manual. ChatRecordingService вкладки B имеет собственный currentCustomTitle = undefined и наивно перезаписал бы его авто-заголовком.
После завершения вызова LLM IIFE повторно читает JSONL через
sessionService.getSessionTitleInfo. Если в файле указан
source: 'manual', IIFE прерывается И синхронизирует своё состояние в памяти, чтобы последующие ходы также учитывали переименование. Стоимость: одно чтение хвоста 64 КБ на успешную генерацию; пренебрежимо мало.
Распространение отмены при finalize()
autoTitleController также выступает флагом выполнения. finalize() (вызывается при переключении сессии и завершении процесса) вызывает
autoTitleController.abort() перед повторной записью заголовка. Сокет LLM отменяется немедленно; переключение сессии не ждёт медленного вызова быстрой модели. Блок finally в IIFE очищает
autoTitleController только если он всё ещё активен, поэтому finalize в процессе выполнения не вступает в гонку с параллельным recordAssistantTurn.
Ручной /rename попадает в процесс выполнения
Между завершением await в IIFE и вызовом recordCustomTitle('auto') пользователь может выполнить /rename foo. IIFE повторно проверяет
this.currentTitleSource === 'manual' и прерывается. Выполняется как внутрипроцессная проверка, так и межпроцессное повторное чтение; ручной вариант побеждает на обоих уровнях.
Конфигурация
Настройки для пользователя
| Настройка / env var | По умолчанию | Эффект |
|---|---|---|
fastModel | не задан | Требуется для авто-титлинга. Не задан → no-op (без fallback на основную модель). |
QWEN_DISABLE_AUTO_TITLE=1 | не задан | Отключение авто-триггера без сброса fastModel. /rename --auto продолжает работать по запросу. |
Переключателя в settings.json нет — env var является единственным видимым пользователю выключателем. Обоснование: функция косметическая и дешёвая; переключатель в настройках добавил бы UI-поверхность для того, что может существовать как одноразовый экспорт env для тех немногих пользователей, кто захочет её отключить.
Почему авто не использует fallback на основную модель
Авто-титлинг срабатывает безусловно после каждого хода ассистента.
Если бы пользователю без быстрой модели молча списывались токены основной модели
за заголовок каждой новой сессии, разница в стоимости стала бы заметна только в ежемесячном счёте. Тихий отказ (no-op, без заголовка, без затрат) — более безопасный вариант по умолчанию. /rename --auto выводит no_fast_model как ошибку, требующую действий, чтобы пользователь мог настроить модель при желании.
Наблюдаемость
createDebugLogger('SESSION_TITLE') выводит debugLogger.warn из блока catch генератора. Ошибки полностью прозрачны для пользователя — авто-заголовок является вспомогательной функцией и никогда не выбрасывает исключения в UI.
Разработчики могут выполнить grep по тегу [SESSION_TITLE] в debug-логе
(~/.qwen/debug/<sessionId>.txt; latest.txt является симлинком на текущую
сессию). Успешный сквозной вызов не генерирует вывод в лог; при ошибке появляется одна строка WARN с сообщением об underlying error.
Усиление безопасности
Значение заголовка выводится в терминал (селектор сессий) в исходном виде И сохраняется в читаемом пользователем JSONL-файле. Обе поверхности подвержены атаке, если скомпрометированная или подвергнутая prompt-инъекции быстрая модель вернёт враждебный текст.
| Угроза | Защита |
|---|---|
| Инъекция ANSI / OSC-8 / CSI | stripTerminalControlSequences перед записью в JSONL и рендером в селекторе. |
| Пронесение кликабельных ссылок через OSC-8 | Аналогично — OSC-последовательности удаляются целиком, а не только байт ESC. |
| Невалидные суррогатные пары UTF-16 | Очищаются в flattenToTail (вход LLM) и sanitizeTitle (выход LLM после обрезки по длине). |
| Спуфинг строки subtype через содержимое сообщения пользователя | lineContains: '"subtype":"custom_title"' — текст пользователя, случайно содержащий эту фразу, не перекроет реальную запись. |
| Перенаправление симлинков при чтении сессий | O_NOFOLLOW (no-op на Windows, где константа отсутствует). |
| Обрезанная конечная запись JSONL | extractLastJsonStringFields требует закрывающую кавычку, прежде чем запись выиграет гонку за последнее совпадение. |
| Патологический размер файла, замораживающий селектор | Лимит MAX_FULL_SCAN_BYTES = 64 MB на полное сканирование файла в Фазе 2. |
Парные CJK-скобки-декораторы (【Draft】) | Удаляются целиком, чтобы одинокая закрывающая скобка не оставалась висячей. |
Вне области применения
| Пункт | Почему нет |
|---|---|
| Авто-регенерация при устаревании заголовка | /rename --auto — это явный путь, запускаемый пользователем. Тихая замена заголовка в середине сессии запутала бы пользователей, прокручивающих селектор. |
| Паритет затемнения в WebUI / VSCode | Эти поверхности уже читают customTitle и будут показывать авто-заголовки как ручные. В следующем обновлении можно будет пробросить titleSource. |
| Переключатель авто-генерации в диалоге настроек | Env var — единственный регулятор. Полный UI настроек легко добавить позже, если появится спрос. |
| Записи в каталоге локалей i18n для новых строк | Соответствует существующим строкам /rename, которые используют английский по умолчанию. Глобальная i18n-доработка репозитория вне области применения. |
| Миграция для реклассификации старых записей | Обратная совместимость заложена в дизайн: отсутствие titleSource трактуется как manual. Перезапись старых записей рисковала бы потерей намерений пользователя. |
| Авто-титлинг в non-interactive режиме | qwen -p / CI-скрипты выбрасывают сессию; токены быстрой модели на заголовок, который никто никогда не возобновит — чистая трата. |