Skip to Content
ДизайнSession TitleПроектирование заголовков сессий

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

Заголовок сессии из 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 --autorenameCommand.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.tsstripTerminalControlSequences, используется в путях 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, а не свободный текст + извлечение тегов:

  1. Надёжность между провайдерами — OpenAI-совместимые эндпоинты, Gemini и нативный tool-calling Qwen поддерживают function calling; парсинг тегов зависел бы от того, соблюдает ли каждая модель текстовое соглашение.
  2. Отсутствие утечки преамбулы рассуждений — аргументы вызова функции возвращаются структурированно, поэтому абзац “thinking” перед ответом не попадёт в заголовок.
  3. Упрощённая постобработка — одной проверки typeof result.title === 'string' плюс sanitizeTitle достаточно для покрытия любого реалистичного дрейфа модели.

Модель всё ещё может вернуть что-то, что разрешено схемой, но UX отвергает (пустая строка, только пробелы, 500 символов, markdown-ограждения, управляющие символы). sanitizeTitle обрабатывает всё это и возвращает '' → сервис возвращает {ok: false, reason: 'empty_result'}.

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

ПараметрЗначениеПричина
modelgetFastModel() — без fallbackАвто-титлинг на токенах основной модели слишком дорог для незаметного выполнения.
schemaTITLE_SCHEMAПринудительно задаёт {title: string}; отфильтровывает дрейф структуры на транспортном уровне.
maxOutputTokens100С запасом хватает на 7 слов плюс накладные расходы схемы.
temperature0.2Преимущественно детерминировано — заголовкам сессий полезна стабильность при перегенерации.
maxAttempts1Заголовки — это косметические метаданные 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 проверяет шесть условий в строгом порядке — каждое прерывает выполнение остальных, чтобы сначала выполнялись дешёвые проверки:

  1. currentCustomTitle установлен → пропуск. Никогда не перезаписывать ручной или предыдущий авто-заголовок.
  2. autoTitleController !== undefined → пропуск. Только одна попытка за раз.
  3. autoTitleAttempts >= 3 → пропуск. Лимит ограничивает общие потери.
  4. !config.isInteractive() → пропуск. Headless qwen -p / CI никогда не тратит токены быстрой модели на одноразовую сессию.
  5. autoTitleDisabledByEnv() → пропуск. Явный отказ через QWEN_DISABLE_AUTO_TITLE=1.
  6. !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 / CSIstripTerminalControlSequences перед записью в JSONL и рендером в селекторе.
Пронесение кликабельных ссылок через OSC-8Аналогично — OSC-последовательности удаляются целиком, а не только байт ESC.
Невалидные суррогатные пары UTF-16Очищаются в flattenToTail (вход LLM) и sanitizeTitle (выход LLM после обрезки по длине).
Спуфинг строки subtype через содержимое сообщения пользователяlineContains: '"subtype":"custom_title"' — текст пользователя, случайно содержащий эту фразу, не перекроет реальную запись.
Перенаправление симлинков при чтении сессийO_NOFOLLOW (no-op на Windows, где константа отсутствует).
Обрезанная конечная запись JSONLextractLastJsonStringFields требует закрывающую кавычку, прежде чем запись выиграет гонку за последнее совпадение.
Патологический размер файла, замораживающий селекторЛимит 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-скрипты выбрасывают сессию; токены быстрой модели на заголовок, который никто никогда не возобновит — чистая трата.
Last updated on