# Session Title Design > A 3-7 word sentence-case session title generated by the fast model after > the first assistant turn. Persisted in the session JSONL with a > `titleSource: 'auto' | 'manual'` tag, surfaced in the session picker, > and regeneratable on demand via `/rename --auto`. ## Overview `/rename` (#3093) lets a user label a session so they can find it again in the picker later, but until they run it the picker shows the first user prompt — often truncated mid-sentence, or describing a framing question rather than what the session actually became about. Manual renaming is optional friction most users never do. The goal is to make session names _useful by default_: - **Descriptive** of what the session actually accomplished, not just the opening line. 3-7 words, sentence case, git-commit-subject style. - **Best-effort**: fires in the background after the first reply; if it fails the user never sees an error. - **Deferential to the user**: never clobber a `/rename` title the user chose deliberately, even across CLI tabs on the same session. - **Explicitly regeneratable** via `/rename --auto` for the "auto title became stale / I want a fresh one" case. ## Triggers | Trigger | Conditions | Implementation | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | **Auto** | After `recordAssistantTurn` fires. Skipped if an existing title is set, another attempt is in-flight, cap reached, non-interactive, env disabled, or no fast model. | `ChatRecordingService.maybeTriggerAutoTitle` — fire-and-forget | | **Manual** | User runs `/rename --auto` | `renameCommand.ts` via `tryGenerateSessionTitle` | Both paths funnel into a single function — `tryGenerateSessionTitle(config, signal)` — to guarantee identical prompt, schema, model selection, and sanitization. The auto trigger is a best-effort background call; the manual `/rename --auto` is a blocking user action that surfaces a reason-specific error on failure. ## Architecture ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ 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 → manual │ │ ─── /rename → kebab │ │ ─── /rename --auto → auto │ │ ─── /rename -- --literal → manual │ │ ─── /rename --unknown-flag → error │ │ │ │ components/SessionPicker.tsx ── dims rows where │ │ session.titleSource === 'auto' │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### Files | File | Responsibility | | ---------------------------------------------------- | ---------------------------------------------------------------------------------- | | `packages/core/src/services/sessionTitle.ts` | One-shot LLM call + history filter + sanitize. Exports `tryGenerateSessionTitle`. | | `packages/core/src/services/chatRecordingService.ts` | `maybeTriggerAutoTitle` trigger, guards, cross-process re-read, abort-on-finalize. | | `packages/core/src/services/sessionService.ts` | `getSessionTitleInfo` public accessor; `renameSession` accepts `titleSource`. | | `packages/core/src/utils/sessionStorageUtils.ts` | `extractLastJsonStringFields` + `readLastJsonStringFieldsSync` atomic pair reader. | | `packages/core/src/utils/terminalSafe.ts` | `stripTerminalControlSequences` shared by sentence-case and kebab paths. | | `packages/cli/src/ui/commands/renameCommand.ts` | `/rename --auto`, sentinel parser, failure-reason message map. | | `packages/cli/src/ui/components/SessionPicker.tsx` | Dim styling for `titleSource === 'auto'`. | ## Prompt Design ### System Prompt Replaces the main agent's system prompt for this single call so the model only tries to label the session, not behave as a coding assistant. Bullets below correspond 1:1 with `TITLE_SYSTEM_PROMPT`: - 3-7 words, sentence case (only first word and proper nouns capitalized). - No trailing punctuation, no markdown, no quotes. - Match the dominant language of the conversation; for Chinese, budget roughly 12-20 characters. - Be specific about the user's actual goal — name the feature, bug, or subject area. Avoid vague catch-alls like "Code changes" or "Help request". - Four good examples (three English + one Chinese) and four bad examples (too vague / too long / wrong case / trailing punctuation). - Return only a JSON object with a single `title` key. ### Structured Output (JSON schema) Instead of wrapping output in tags (as session-recap does), we use `BaseLlmClient.generateJson` with a function-calling schema: ```ts const TITLE_SCHEMA = { type: 'object', properties: { title: { type: 'string', description: 'A concise sentence-case session title, 3-7 words, no trailing punctuation.', }, }, required: ['title'], }; ``` Why function calling rather than free text + tag extraction: 1. Cross-provider reliability — OpenAI-compatible endpoints, Gemini, and Qwen's native tool-calling all implement function calling; tag parsing would rely on every model respecting a text convention. 2. No reasoning-preamble leakage — the function call arguments come back structured, so a "thinking" paragraph before the answer can't bleed into the title. 3. Simpler post-processing — a single `typeof result.title === 'string'` check plus `sanitizeTitle` covers every realistic model drift. The model may still return something the schema allows but the UX rejects (empty string, whitespace-only, 500 chars, markdown fencing, control chars). `sanitizeTitle` handles all of these and returns `''` → service returns `{ok: false, reason: 'empty_result'}`. ### Call Parameters | Parameter | Value | Reason | | ----------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- | | `model` | `getFastModel()` — no fallback | Auto-titling on main-model tokens is too expensive to be silent. | | `schema` | `TITLE_SCHEMA` | Forces `{title: string}`; filters shape drift at the transport layer. | | `maxOutputTokens` | `100` | More than enough for 7 words plus schema overhead. | | `temperature` | `0.2` | Mostly deterministic — session titles benefit from stability across regeneration. | | `maxAttempts` | `1` | Titles are best-effort cosmetic metadata; retries would queue behind user-visible main traffic. | Contrast with session-recap, which falls back to the main model. Title generation is triggered automatically and often; silently spending main-model tokens without a user opt-in is a real bill surprise. Manual `/rename --auto` explicitly fails with `no_fast_model` rather than fallback — forcing the user to make the fast-model choice consciously. ## History Filtering `geminiClient.getChat().getHistory()` returns `Content[]` that includes tool calls, tool responses (often 10K+ tokens of file content), and model thought parts. Feeding that raw into the title LLM would bias the label toward implementation noise like "Called grep on auth module". `filterToDialog` keeps only `user` / `model` entries with non-empty text and no `thought` / `thoughtSignature` parts. `takeRecentDialog` slices to the last 20 messages and refuses to start on a dangling model/tool response. `flattenToTail` converts to "Role: text" lines and slices the last 1000 characters. ### The 1000-character tail slice A session that starts with `help me debug X` but pivots to refactoring Y should be titled about Y. Titling by the head locks in the opening framing; titling by the tail captures what the session became. ### UTF-16 surrogate handling `.slice(-1000)` on a UTF-16 code-unit boundary can orphan a high or low surrogate if a CJK supplementary char or emoji gets cut. Some providers respond to the resulting invalid UTF-16 with a 400 — which, without handling, would burn an attempt for no reason. `flattenToTail` drops a leading orphaned low surrogate; `sanitizeTitle` scrubs any orphaned surrogate after the max-length trim on the output path too. ## Persistence ### Record shape `CustomTitleRecordPayload` grows an optional `titleSource: 'auto' | 'manual'` field: ```jsonc { "type": "system", "subtype": "custom_title", "systemPayload": { "customTitle": "Debug login button on mobile", "titleSource": "auto", }, } ``` The field is optional, and absent-in-legacy records are treated as `undefined`. `SessionPicker` dims rows only on a strict `=== 'auto'` match — a pre-change user `/rename` title is never silently reclassified as a model guess. ### Resume hydration On resume, `ChatRecordingService` constructor calls `sessionService.getSessionTitleInfo(sessionId)` to read **both** the title and its source. Without hydrating the source, `finalize()`'s re-append (which runs on every session lifecycle event) would rewrite auto as manual on every resume cycle — silently stripping the dim affordance. ### Atomic pair read `extractLastJsonStringFields` returns `customTitle` and `titleSource` from the **same matching line** in a single scan. Two separate `readLastJsonStringFieldSync` calls could land on different records if an older line has only the primary field, yielding a mismatched pair. The extractor also requires a proper closing quote on the primary value, so a crash-truncated trailing record can't win the latest-match race. ### Full-file scan cap Phase-2 (when the tail-window fast path misses) streams the whole file in 64KB chunks. Capped at `MAX_FULL_SCAN_BYTES = 64 MB` so a corrupt multi-GB JSONL can't freeze the session picker on the main event loop. The picker's latency envelope survives corruption. ### Symlink defense Session reads open with `O_NOFOLLOW` (falls back to plain read-only on Windows, where the constant is not exposed). Defense in depth so a symlink planted in `~/.qwen/projects//chats/` can't redirect a metadata read to an unrelated file. ## Concurrency and Edge Cases ### Trigger guard order `maybeTriggerAutoTitle` checks six conditions in this exact order — each short-circuits the rest so the cheap ones run first: 1. `currentCustomTitle` set → skip. Never overwrite manual / prior auto. 2. `autoTitleController !== undefined` → skip. One attempt at a time. 3. `autoTitleAttempts >= 3` → skip. Cap bounds total waste. 4. `!config.isInteractive()` → skip. Headless `qwen -p` / CI never spends fast-model tokens on a one-shot session. 5. `autoTitleDisabledByEnv()` → skip. `QWEN_DISABLE_AUTO_TITLE=1` explicit opt-out. 6. `!config.getFastModel()` → skip. No fast-model → no-op. ### Why the cap is 3, not 1 The first assistant turn can be a pure tool-call with no user-visible text (e.g. the model opens with a `grep`). `tryGenerateSessionTitle` returns `{ok: false, reason: 'empty_history'}` in that case. Without a retry window, an entire session's chance at a title would be burned on turn 1 before the user said anything interesting. Cap of 3 covers the common "first turn is noise" case while still bounding runaway retry on a persistently failing fast model. ### Cross-process manual-rename race Two CLI tabs on the same session file can diverge in memory. Tab A runs `/rename foo` and writes `titleSource: manual`. Tab B's `ChatRecordingService` has its own `currentCustomTitle = undefined` and would naively overwrite with an auto title. After the LLM call resolves, the IIFE re-reads the JSONL via `sessionService.getSessionTitleInfo`. If the file shows `source: 'manual'`, the IIFE bails AND syncs its in-memory state so subsequent turns respect the rename too. Cost: one 64KB tail read per successful generation; negligible. ### Abort propagation on `finalize()` `autoTitleController` doubles as the in-flight flag. `finalize()` (run on session switch and process shutdown) calls `autoTitleController.abort()` before re-appending the title record. The LLM socket is cancelled promptly; session switch doesn't wait on a slow fast-model call. The IIFE's `finally` block clears `autoTitleController` only if it's still the active one, so a finalize mid-flight doesn't race a concurrent `recordAssistantTurn`. ### Manual `/rename` lands mid-flight Between the IIFE's `await` completing and the `recordCustomTitle('auto')` call, the user could `/rename foo`. The IIFE re-checks `this.currentTitleSource === 'manual'` and bails. The in-process check AND the cross-process re-read both run; manual wins at both layers. ## Configuration ### User-facing knobs | Setting / env var | Default | Effect | | --------------------------- | ------- | --------------------------------------------------------------------------------------------------- | | `fastModel` | unset | Required for auto-titling. Unset → no-op (no main-model fallback). | | `QWEN_DISABLE_AUTO_TITLE=1` | unset | Opt out of the auto trigger without unsetting `fastModel`. `/rename --auto` still works on request. | No `settings.json` toggle — the env var is the only user-visible off-switch. Rationale: the feature is cosmetic and cheap; a settings toggle would add a UI surface for something that can live as a one-time env export for the few users who want to disable it. ### Why auto doesn't fall back to the main model Auto-titling is triggered unconditionally after every assistant turn. If a user without a fast model were silently charged main-model tokens for every new session's title, the cost delta is invisible until the monthly bill arrives. Failing quietly (no-op, no title, no cost) is the safer default. `/rename --auto` surfaces `no_fast_model` as an actionable error so the user can set one if they want to. ## Observability `createDebugLogger('SESSION_TITLE')` emits `debugLogger.warn` from the generator's catch block. Failures are fully transparent to the user — auto-title is an auxiliary feature and never throws into the UI. Developers can grep for the `[SESSION_TITLE]` tag in the debug log (`~/.qwen/debug/.txt`; `latest.txt` symlinks to the current session). A working end-to-end call produces no log output; a failing one gets one WARN line with the underlying error message. ## Security Hardening The title value is rendered verbatim in the terminal (session picker) AND persisted in a user-readable JSONL file. Both surfaces are attack reachable if a compromised or prompt-injected fast model returns hostile text. | Concern | Guard | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | ANSI / OSC-8 / CSI injection | `stripTerminalControlSequences` before both JSONL write and picker render. | | Clickable-link smuggle via OSC-8 | Same — OSC sequences stripped as whole units, not just the ESC byte. | | Invalid UTF-16 surrogates | Scrubbed in `flattenToTail` (LLM input) and `sanitizeTitle` (LLM output after max-length trim). | | Subtype-line spoof via user message content | `lineContains: '"subtype":"custom_title"'` — user text that happens to contain the literal phrase can't shadow a real record. | | Symlink redirect on session reads | `O_NOFOLLOW` (no-op on Windows where the constant is missing). | | Truncated trailing JSONL record | `extractLastJsonStringFields` requires a closing quote before a record wins the latest-match race. | | Pathological file size freezing the picker | `MAX_FULL_SCAN_BYTES = 64 MB` cap on Phase-2 full-file scan. | | Paired CJK bracket decorators (`【Draft】`) | Stripped as a unit so a lone closing bracket doesn't dangle. | ## Out of Scope | Item | Why not | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Auto-regenerate when the title goes stale | `/rename --auto` is the explicit user-triggered path. Silent mid-session title swaps would confuse users scrolling back through the picker. | | WebUI / VSCode dim-styling parity | Those surfaces read `customTitle` already and will show auto titles as if manual. A follow-up can wire the `titleSource` through. | | Settings-dialog toggle for auto generation | Env var is the single knob. Full settings UI is easy to add later if user demand surfaces. | | i18n locale catalog entries for new strings | Consistent with existing `/rename` strings, which fall through to English. A repo-wide i18n pass is out of scope. | | Migration to re-classify legacy records | Back-compat by design: absent `titleSource` is treated as manual. Rewriting old records would risk losing user intent. | | Non-interactive auto-titling | `qwen -p` / CI scripts throw the session away; fast-model tokens for a title no one will ever resume is pure waste. |