docs/design/session-title/session-title-design.md
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.
/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:
/rename title the user
chose deliberately, even across CLI tabs on the same session./rename --auto for the "auto title
became stale / I want a fresh one" case.| Trigger | Conditions | Implementation |
|---|---|---|
| Auto | After recordAssistantTurn fires. Skipped if an existing or pending explicit title is set, recording has failed, 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.
┌─────────────────────────────────────────────────────────────────────────┐
│ packages/core/src/services/ │
│ │
│ ┌──────────────────────────┐ │
│ │ chatRecordingService.ts │ │
│ │ │ │
│ │ recordAssistantTurn() │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ maybeTriggerAutoTitle() │── 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 persistCustomTitle │
│ │ 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' │
└─────────────────────────────────────────────────────────────────────────┘
| 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'. |
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:
title key.Instead of wrapping output in tags (as session-recap does), we use
BaseLlmClient.generateJson with a function-calling schema:
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:
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'}.
| 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.
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.
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.
.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.
ChatRecordingService.recordCustomTitle() returns Promise<boolean>.
true means the custom_title record has completed the recorder's ordered
JSONL write, the in-memory title and source have been updated, and the title
observer has been notified. It does not mean merely accepted into the write
queue. A file-creation failure, an existing recorder failure, an earlier
queued write failure, or the title write's own rejection returns false and
leaves the prior title and observer state unchanged. The recorder's canonical
flush() still reports the sticky writer failure.
The observer runs only after persistence. Observer exceptions are isolated
because an already written title cannot be retroactively classified as a
failed rename. The callback also receives the session ID captured in the
persisted record; consumers must not read the mutable Config session at callback
time. This prevents a delayed write from an outgoing recorder from renaming the
new prompt bar after /clear, resume, or branch switches sessions.
Background auto-title uses the same private persistence path but remains
best-effort. Public calls are explicit title requests, including
recordCustomTitle(title, 'auto') from /rename --auto and remote control
surfaces.
CustomTitleRecordPayload grows an optional titleSource: 'auto' | 'manual' field:
{
"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.
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.
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.
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.
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/<proj>/chats/ can't redirect a
metadata read to an unrelated file.
maybeTriggerAutoTitle checks these conditions in this exact order — each
short-circuits the rest so the cheap ones run first:
currentCustomTitle set → skip. Never overwrite manual / prior auto.autoTitleController !== undefined → skip. One attempt at a time.autoTitleAttempts >= 3 → skip. Cap bounds total waste.autoTitleDisabledByEnv() → skip. QWEN_DISABLE_AUTO_TITLE=1
explicit opt-out.!config.isInteractive() → skip. Headless qwen -p / CI never spends
fast-model tokens on a one-shot session.!config.getFastModel() → skip. No fast-model → no-op.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.
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.
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.
Every public recordCustomTitle() call increments a pending-explicit counter
and aborts the active background controller before it waits for JSONL. This
includes /rename --auto, so a model-generated explicit rename has the same
priority as a literal user title. The background task checks the counter,
abort signal, current title, and writer state both after generation and just
before persistence. Concurrent explicit renames are serialized by the writer;
their in-memory results commit in that same order. If an explicit title arrives
after a background title is already queued, normal writer ordering makes the
explicit title the final record.
While an explicit title write is pending, finalize() does not re-anchor the
previous cached title. Otherwise that stale record could be queued behind the
rename and become the JSONL tail after the new title successfully lands.
Title-anchor accounting follows logical writer queue order. Descendant records
queued behind any unresolved title write are counted, but threshold re-anchor
is deferred until the final cached title is known, so neither shutdown nor the
32KB anchor path can append a stale title behind a successful rename.
The cross-process re-read still protects manual titles written by another CLI process. It is not a compare-and-swap; an external rename in the final check-to- write window remains an accepted race.
/branch finalizes and flushes the source recorder before copying it. It then
loads the provisional fork, computes a unique branch title, persists that title
through SessionService, and reloads the fork so its final tail and title cache
include the appended record. Only after those steps succeed does it switch the
core recorder and UI. A source flush failure, title failure, or final reload
failure deletes the incomplete fork and leaves the original session active.
forkSession() also removes its exclusive-created target if writing or closing
the new JSONL fails before the call can report success, so the caller cannot
leak a partial file during the window before it marks the fork as created.
Live ACP title operations await recordCustomTitle() and therefore report a
truthful persisted or success result without a second flush() that could
attribute a later unrelated write failure to the rename. The daemon metadata
PATCH remains intentionally optimistic: it updates live state and publishes
its event immediately, while child persistence is best-effort and a false
result is debug-logged. Strongly consistent HTTP metadata updates are outside
this design.
| 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.
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.
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/<sessionId>.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.
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. |
| 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. |