feat(session): auto-title sessions via fast model, add /rename --auto#3540
Conversation
The /rename work in QwenLM#3093 generates kebab-case titles only when the user explicitly runs `/rename` with no args; until they do, the session picker shows the first user prompt (often truncated or misleading). This change adds a sentence-case auto-title that fires once per session after the first assistant turn, using the configured fast model. New service: `packages/core/src/services/sessionTitle.ts` — `tryGenerateSessionTitle(config, signal)` returns a discriminated outcome (`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can either handle failures generically or map reasons to actionable messages. Prompt shape: 3-7 words, sentence case, good/bad examples including a CJK row, JSON schema enforced via `baseLlmClient.generateJson`. `maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight rate limits. Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after `recordAssistantTurn`. Fire-and-forget promise, guarded by: - `currentCustomTitle` — don't overwrite any existing title. - `autoTitleController` doubles as in-flight flag; a second turn while the first is still pending is a no-op. - `autoTitleAttempts` cap of 3 — the first assistant turn may be a pure tool-call with no user-visible text; retry for a handful of turns until a title lands. Cap bounds total waste. - `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto- titles; spending fast-model tokens on a one-shot session is waste. - `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out. - `config.getFastModel()` falsy — skip entirely rather than falling back to the main model; auto-titling on main-model tokens is too expensive to be silent. Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' | 'manual'` field. Absent on pre-change records (treated as `undefined` → manual, safe default so a user's pre-upgrade `/rename` is never silently reclassified). `SessionPicker` renders `titleSource === 'auto'` titles in dim (secondary) color; manual stays full contrast. On resume, the persisted source is rehydrated into `currentTitleSource` — without this, finalize's re-append would rewrite an auto title as manual on every resume cycle. Cross-process manual-rename guard: when two CLI tabs target the same JSONL, in-memory state can diverge. Before writing an auto record, the IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a `/rename` from another process landed as manual, bail and sync local state — never clobber a deliberately-chosen manual title with a model guess. Cost is one 64KB tail read per successful generation. `finalize()` aborts the in-flight controller before re-appending the title record. Session switch / shutdown doesn't have to wait on a slow fast-model call. New user-facing command: `/rename --auto` regenerates via the same generator — explicit user trigger, overwrites whatever's there (manual or auto) because the user asked. Errors route through `autoFailureMessage(reason)` so `empty_history`, `model_error`, `aborted`, etc. each get actionable guidance rather than a generic "could not generate". `/rename -- --literal-name` is the sentinel for titles that start with `--`; unknown `--flag` tokens error with a hint pointing at the sentinel. Existing `/rename <name>` and bare `/rename` (kebab-case via existing path) are unchanged, except the kebab path now prefers fast model when available and runs its output through `stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the sentence-case path). New shared util: `packages/core/src/utils/terminalSafe.ts` — `stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI (\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise execute on every SessionPicker render; both sentence-case and kebab paths now route titles through the helper before they reach the JSONL or the UI. Tail-read extractor: `extractLastJsonStringFields(text, primaryKey, otherKeys, lineContains)` reads multiple fields from the same matching line in a single pass. Two separate tail scans could return a mismatched pair (primary from a newer record, secondary from an older one with only the primary set); the new helper guarantees the pair is atomic. Validates a proper closing quote on the primary value so a crash-truncated trailing record can't win the latest-match race. `readLastJsonStringFieldsSync` is its file-reading wrapper — same tail-window fast path and full-file fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB` cap so a corrupt multi-GB session file can't freeze the picker. Session reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows where the constant isn't exposed) — defense in depth against a symlink planted in `~/.qwen/projects/<proj>/chats/`. Character handling: `flattenToTail` on the LLM prompt drops a dangling low surrogate after `slice(-1000)` — otherwise a CJK supplementary char or emoji cut mid-pair produces invalid UTF-16 that some providers 400. `sanitizeTitle` applies the same surrogate scrub after max-length trim, and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char strip. `lineContains` in the title reader is tightened from the loose substring `'custom_title'` to `'"subtype":"custom_title"'` so user text containing the literal `custom_title` can't shadow a real record. Tests: 46 new unit tests across - `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets. - `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix, in-flight guard, abort propagation on finalize, manual/auto/legacy resume symmetry, cross-process race, env opt-out, retry-after- transient. - `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle boundary, truncated trailing record, lineContains, multi-field atom. - `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel, unknown-flag hint, positional rejection, manual/SessionService fallbacks.
wenshao
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅ — gpt-5.4 via Qwen Code /review
Matches the session-recap design doc shape (Overview / Triggers / Architecture / Prompt Design / History Filtering / Persistence / Concurrency / Configuration / Observability / Out of Scope) and adds a Security Hardening section unique to the title path — titles render directly in the picker and persist in user-readable JSONL, so LLM-returned control sequences are an attack surface the recap path doesn't have. Captures decisions a code-only reader has to reverse-engineer: - Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop). - Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call). - Why the auto trigger does NOT fall back to the main model but session-recap does (auto-title fires on every turn; silently charging main-model tokens is a bill surprise). - Why `titleSource: undefined` stays unwritten on legacy records (no rewrite risks silently reclassifying user intent). - Why the cross-process re-read sits between the LLM await and the append (manual wins at both in-process and on-disk layers). - Why `finalize()`'s abort tolerates a controller swap (in-flight identity check). - Why JSON-schema function calling instead of tag extraction (avoid reasoning preamble bleed; cross-provider reliability). Placed at docs/design/session-title/ alongside session-recap, compact-mode, fork-subagent, and other per-feature design docs. No sidebar index update required — the design folder is unindexed.
|
One thing worth tightening before merge — the bare The changePre-PR, bare const model = config.getFastModel() ?? config.getModel();If a user has Why it matters
The PR body contradicts itself
These two clauses can't both be true. Suggested minimum fixes (non-blocking)
Rest of the PR looks solid; the defensive layering in the title extractor and the single-pass |
|
One more note — asymmetric cross-process guard in the resume path (non-blocking, follow-up):
The resume path in the constructor ( if (config.getResumedSessionData()) {
const info = sessionService.getSessionTitleInfo(config.getSessionId());
this.currentCustomTitle = info.title;
this.currentTitleSource = info.source;
this.finalize(); // re-append to EOF, no re-read in between
}Scenario: Tab A has session S open; Tab B opens the same S via Windows are small but the tests don't cover it: Suggested follow-upMirror the Happy to pick this up as a follow-up — @qqqys will own it after this PR merges. Overall LGTM! Nice work on the defensive layering (the |
|
One open UX question for the author — worth confirming the intended behavior: Should the auto-generated title be reflected in the UI immediately after the first-turn trigger fires? Currently it isn't. Verified by running the feature end-to-end against a real session JSONL:
Observed effect: in the running session, the user sees nothing change — no toast, no bottom-bar label update, no history-region info message. The generated title only surfaces on next launch, when This is confirmed by two real session files on my machine (both on this branch, with The auto-title was silently correct in the file, but I never saw it in the CLI while that session was running. Prior art — how Claude Code handles thisClaude Code's interactive REPL wires the same fire-and-forget into a React state setter plus an OSC-based terminal-tab-title update ( Options
Not a blocker — the feature is internally correct — but (1) or (3) would make a real UX difference. Happy to scope this into the same follow-up as the resume-finalize re-read (qqqys will own). @wenshao — which of the options reflects the intended design? If (4) is by design, no change needed; if not, want me to pick this up in the follow-up? |
Addresses reviewer feedback: the bare `/rename` model selection (`config.getFastModel() ?? config.getModel()`) had no test pinning it either way. Previous tests mocked `getHistory: []`, which exits the function before the model is ever chosen, so a silent regression to either direction (always-main or always-fast) would pass CI. Two explicit cases now: - fastModel set → `generateContent` called with `model: 'qwen-turbo'`. - fastModel unset → `generateContent` called with `model: 'main-model'`. The tests intentionally mock a non-empty history so the kebab path reaches the generateContent call site instead of bailing on empty input.
|
Good catch on the contradiction — you're right, I was trying to have it both ways. Fixed: Updated PR body (Risk / compatibility section): replaced the "behavior unchanged" claim with an explicit callout that bare Added tests in
Both use a non-empty history so the kebab path actually reaches Confirmed locally: |
|
Valid finding — confirmed by re-reading the constructor path: the resume branch reads Happy for you to pick this up as a follow-up. Sketch of the fix for when you get to it: // in finalize(), before the re-append:
if (this.autoTitleController) {
try { this.autoTitleController.abort(); } catch {}
}
if (!this.currentCustomTitle) return;
// NEW: re-read on-disk to detect cross-process change since we hydrated
try {
const onDisk = this.config.getSessionService()
.getSessionTitleInfo(this.config.getSessionId());
if (onDisk.title && onDisk.title !== this.currentCustomTitle) {
// Another process updated between our resume read and now — bail.
this.currentCustomTitle = onDisk.title;
this.currentTitleSource = onDisk.source;
return;
}
} catch { /* best-effort */ }
// ... existing re-append ...Test: mock |
|
Thanks for the end-to-end verification, that's exactly the signal I didn't have. The current behavior (silent, only visible on next launch) was a deliberate scope cut rather than a considered design — in tmux I'd noticed the picker was correct after exit and called it done. Your readout clarifies it's a real UX gap, not the intended final state. My preference is option (3): transient info message in the history region. Rationale:
Option (1) or (2) are nice additions on top, not alternatives — doing (3) first doesn't block them. Please do pick this up in the follow-up alongside the constructor re-read guard. Happy to review. |
|
Overall LGTM! 🎉 Solid piece of work — the defensive layering (single-pass Summary of open items from this review, all non-blocking — I'll (@qqqys) take them as a follow-up after this merges:
None of these block shipping. Great work @wenshao — happy to iterate on the above in a follow-up PR. |
…QwenLM#3540) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…#3540) * feat(session): auto-title sessions via fast model, add /rename --auto The /rename work in #3093 generates kebab-case titles only when the user explicitly runs `/rename` with no args; until they do, the session picker shows the first user prompt (often truncated or misleading). This change adds a sentence-case auto-title that fires once per session after the first assistant turn, using the configured fast model. New service: `packages/core/src/services/sessionTitle.ts` — `tryGenerateSessionTitle(config, signal)` returns a discriminated outcome (`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can either handle failures generically or map reasons to actionable messages. Prompt shape: 3-7 words, sentence case, good/bad examples including a CJK row, JSON schema enforced via `baseLlmClient.generateJson`. `maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight rate limits. Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after `recordAssistantTurn`. Fire-and-forget promise, guarded by: - `currentCustomTitle` — don't overwrite any existing title. - `autoTitleController` doubles as in-flight flag; a second turn while the first is still pending is a no-op. - `autoTitleAttempts` cap of 3 — the first assistant turn may be a pure tool-call with no user-visible text; retry for a handful of turns until a title lands. Cap bounds total waste. - `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto- titles; spending fast-model tokens on a one-shot session is waste. - `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out. - `config.getFastModel()` falsy — skip entirely rather than falling back to the main model; auto-titling on main-model tokens is too expensive to be silent. Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' | 'manual'` field. Absent on pre-change records (treated as `undefined` → manual, safe default so a user's pre-upgrade `/rename` is never silently reclassified). `SessionPicker` renders `titleSource === 'auto'` titles in dim (secondary) color; manual stays full contrast. On resume, the persisted source is rehydrated into `currentTitleSource` — without this, finalize's re-append would rewrite an auto title as manual on every resume cycle. Cross-process manual-rename guard: when two CLI tabs target the same JSONL, in-memory state can diverge. Before writing an auto record, the IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a `/rename` from another process landed as manual, bail and sync local state — never clobber a deliberately-chosen manual title with a model guess. Cost is one 64KB tail read per successful generation. `finalize()` aborts the in-flight controller before re-appending the title record. Session switch / shutdown doesn't have to wait on a slow fast-model call. New user-facing command: `/rename --auto` regenerates via the same generator — explicit user trigger, overwrites whatever's there (manual or auto) because the user asked. Errors route through `autoFailureMessage(reason)` so `empty_history`, `model_error`, `aborted`, etc. each get actionable guidance rather than a generic "could not generate". `/rename -- --literal-name` is the sentinel for titles that start with `--`; unknown `--flag` tokens error with a hint pointing at the sentinel. Existing `/rename <name>` and bare `/rename` (kebab-case via existing path) are unchanged, except the kebab path now prefers fast model when available and runs its output through `stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the sentence-case path). New shared util: `packages/core/src/utils/terminalSafe.ts` — `stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI (\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise execute on every SessionPicker render; both sentence-case and kebab paths now route titles through the helper before they reach the JSONL or the UI. Tail-read extractor: `extractLastJsonStringFields(text, primaryKey, otherKeys, lineContains)` reads multiple fields from the same matching line in a single pass. Two separate tail scans could return a mismatched pair (primary from a newer record, secondary from an older one with only the primary set); the new helper guarantees the pair is atomic. Validates a proper closing quote on the primary value so a crash-truncated trailing record can't win the latest-match race. `readLastJsonStringFieldsSync` is its file-reading wrapper — same tail-window fast path and full-file fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB` cap so a corrupt multi-GB session file can't freeze the picker. Session reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows where the constant isn't exposed) — defense in depth against a symlink planted in `~/.qwen/projects/<proj>/chats/`. Character handling: `flattenToTail` on the LLM prompt drops a dangling low surrogate after `slice(-1000)` — otherwise a CJK supplementary char or emoji cut mid-pair produces invalid UTF-16 that some providers 400. `sanitizeTitle` applies the same surrogate scrub after max-length trim, and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char strip. `lineContains` in the title reader is tightened from the loose substring `'custom_title'` to `'"subtype":"custom_title"'` so user text containing the literal `custom_title` can't shadow a real record. Tests: 46 new unit tests across - `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets. - `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix, in-flight guard, abort propagation on finalize, manual/auto/legacy resume symmetry, cross-process race, env opt-out, retry-after- transient. - `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle boundary, truncated trailing record, lineContains, multi-field atom. - `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel, unknown-flag hint, positional rejection, manual/SessionService fallbacks. * docs(session): design doc for auto session titles Matches the session-recap design doc shape (Overview / Triggers / Architecture / Prompt Design / History Filtering / Persistence / Concurrency / Configuration / Observability / Out of Scope) and adds a Security Hardening section unique to the title path — titles render directly in the picker and persist in user-readable JSONL, so LLM-returned control sequences are an attack surface the recap path doesn't have. Captures decisions a code-only reader has to reverse-engineer: - Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop). - Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call). - Why the auto trigger does NOT fall back to the main model but session-recap does (auto-title fires on every turn; silently charging main-model tokens is a bill surprise). - Why `titleSource: undefined` stays unwritten on legacy records (no rewrite risks silently reclassifying user intent). - Why the cross-process re-read sits between the LLM await and the append (manual wins at both in-process and on-disk layers). - Why `finalize()`'s abort tolerates a controller swap (in-flight identity check). - Why JSON-schema function calling instead of tag extraction (avoid reasoning preamble bleed; cross-provider reliability). Placed at docs/design/session-title/ alongside session-recap, compact-mode, fork-subagent, and other per-feature design docs. No sidebar index update required — the design folder is unindexed. * test(rename): pin model choice in bare /rename kebab path Addresses reviewer feedback: the bare `/rename` model selection (`config.getFastModel() ?? config.getModel()`) had no test pinning it either way. Previous tests mocked `getHistory: []`, which exits the function before the model is ever chosen, so a silent regression to either direction (always-main or always-fast) would pass CI. Two explicit cases now: - fastModel set → `generateContent` called with `model: 'qwen-turbo'`. - fastModel unset → `generateContent` called with `model: 'main-model'`. The tests intentionally mock a non-empty history so the kebab path reaches the generateContent call site instead of bailing on empty input.
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session renaming/deletion + custom-title support. Skips the auto-title-via-LLM piece (depends on un-ported gateway shape) and the vscode-ide-companion files (deleted in our fork). What's in: - /rename: prompt for a custom session title; persisted via ChatRecordingService.recordCustomTitle and surfaced in the picker. - /delete: opens a SessionPicker that calls SessionService.removeSession on selection. - SessionListItem.customTitle field + readSessionTitleFromFile tail scanner on session file load. - SessionService.renameSession / getSessionTitle / findSessionsByTitle. - ACP extMethod handlers for renameSession + deleteSession. - SessionStart restores session-name tag from the persisted custom title via useInitializationEffects. - --resume now accepts UUID or title (validation moved to runtime). Conflict resolution notes: - Kept HEAD's bg-agent useEffect block; the upstream init useEffect was already extracted into useInitializationEffects, so the customTitle restore goes there with an optional setSessionName arg. - Kept HEAD's rewind dialog; added the delete dialog as a sibling. - Kept HEAD's voice/recap state; added sessionName/setSessionName to UIState. Dropped upstream's streamingResponseLengthRef + isReceivingContent (token-display PR QwenLM#3329, un-ported). - Dropped upstream MemoryDialog import (auto-memory un-ported); kept the i18n t import for the Delete dialog title. Tests: 29 new tests pass (rename/delete commands, customTitle recording, sessionService rename/find). Resume tests still pass. Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a generateSessionTitle path through ContentGenerator that needs adaptation to our gateway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session renaming/deletion + custom-title support. Skips the auto-title-via-LLM piece (depends on un-ported gateway shape) and the vscode-ide-companion files (deleted in our fork). What's in: - /rename: prompt for a custom session title; persisted via ChatRecordingService.recordCustomTitle and surfaced in the picker. - /delete: opens a SessionPicker that calls SessionService.removeSession on selection. - SessionListItem.customTitle field + readSessionTitleFromFile tail scanner on session file load. - SessionService.renameSession / getSessionTitle / findSessionsByTitle. - ACP extMethod handlers for renameSession + deleteSession. - SessionStart restores session-name tag from the persisted custom title via useInitializationEffects. - --resume now accepts UUID or title (validation moved to runtime). Conflict resolution notes: - Kept HEAD's bg-agent useEffect block; the upstream init useEffect was already extracted into useInitializationEffects, so the customTitle restore goes there with an optional setSessionName arg. - Kept HEAD's rewind dialog; added the delete dialog as a sibling. - Kept HEAD's voice/recap state; added sessionName/setSessionName to UIState. Dropped upstream's streamingResponseLengthRef + isReceivingContent (token-display PR QwenLM#3329, un-ported). - Dropped upstream MemoryDialog import (auto-memory un-ported); kept the i18n t import for the Delete dialog title. Tests: 29 new tests pass (rename/delete commands, customTitle recording, sessionService rename/find). Resume tests still pass. Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a generateSessionTitle path through ContentGenerator that needs adaptation to our gateway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…) (#218) * feat(session): port /rename and /delete with custom titles (QwenLM#3093) Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session renaming/deletion + custom-title support. Skips the auto-title-via-LLM piece (depends on un-ported gateway shape) and the vscode-ide-companion files (deleted in our fork). What's in: - /rename: prompt for a custom session title; persisted via ChatRecordingService.recordCustomTitle and surfaced in the picker. - /delete: opens a SessionPicker that calls SessionService.removeSession on selection. - SessionListItem.customTitle field + readSessionTitleFromFile tail scanner on session file load. - SessionService.renameSession / getSessionTitle / findSessionsByTitle. - ACP extMethod handlers for renameSession + deleteSession. - SessionStart restores session-name tag from the persisted custom title via useInitializationEffects. - --resume now accepts UUID or title (validation moved to runtime). Conflict resolution notes: - Kept HEAD's bg-agent useEffect block; the upstream init useEffect was already extracted into useInitializationEffects, so the customTitle restore goes there with an optional setSessionName arg. - Kept HEAD's rewind dialog; added the delete dialog as a sibling. - Kept HEAD's voice/recap state; added sessionName/setSessionName to UIState. Dropped upstream's streamingResponseLengthRef + isReceivingContent (token-display PR QwenLM#3329, un-ported). - Dropped upstream MemoryDialog import (auto-memory un-ported); kept the i18n t import for the Delete dialog title. Tests: 29 new tests pass (rename/delete commands, customTitle recording, sessionService rename/find). Resume tests still pass. Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a generateSessionTitle path through ContentGenerator that needs adaptation to our gateway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: nudge PR conflict recomputation --------- Co-authored-by: Automaker <automaker@localhost> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…QwenLM#3540) * feat(session): auto-title sessions via fast model, add /rename --auto The /rename work in QwenLM#3093 generates kebab-case titles only when the user explicitly runs `/rename` with no args; until they do, the session picker shows the first user prompt (often truncated or misleading). This change adds a sentence-case auto-title that fires once per session after the first assistant turn, using the configured fast model. New service: `packages/core/src/services/sessionTitle.ts` — `tryGenerateSessionTitle(config, signal)` returns a discriminated outcome (`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can either handle failures generically or map reasons to actionable messages. Prompt shape: 3-7 words, sentence case, good/bad examples including a CJK row, JSON schema enforced via `baseLlmClient.generateJson`. `maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight rate limits. Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after `recordAssistantTurn`. Fire-and-forget promise, guarded by: - `currentCustomTitle` — don't overwrite any existing title. - `autoTitleController` doubles as in-flight flag; a second turn while the first is still pending is a no-op. - `autoTitleAttempts` cap of 3 — the first assistant turn may be a pure tool-call with no user-visible text; retry for a handful of turns until a title lands. Cap bounds total waste. - `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto- titles; spending fast-model tokens on a one-shot session is waste. - `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out. - `config.getFastModel()` falsy — skip entirely rather than falling back to the main model; auto-titling on main-model tokens is too expensive to be silent. Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' | 'manual'` field. Absent on pre-change records (treated as `undefined` → manual, safe default so a user's pre-upgrade `/rename` is never silently reclassified). `SessionPicker` renders `titleSource === 'auto'` titles in dim (secondary) color; manual stays full contrast. On resume, the persisted source is rehydrated into `currentTitleSource` — without this, finalize's re-append would rewrite an auto title as manual on every resume cycle. Cross-process manual-rename guard: when two CLI tabs target the same JSONL, in-memory state can diverge. Before writing an auto record, the IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a `/rename` from another process landed as manual, bail and sync local state — never clobber a deliberately-chosen manual title with a model guess. Cost is one 64KB tail read per successful generation. `finalize()` aborts the in-flight controller before re-appending the title record. Session switch / shutdown doesn't have to wait on a slow fast-model call. New user-facing command: `/rename --auto` regenerates via the same generator — explicit user trigger, overwrites whatever's there (manual or auto) because the user asked. Errors route through `autoFailureMessage(reason)` so `empty_history`, `model_error`, `aborted`, etc. each get actionable guidance rather than a generic "could not generate". `/rename -- --literal-name` is the sentinel for titles that start with `--`; unknown `--flag` tokens error with a hint pointing at the sentinel. Existing `/rename <name>` and bare `/rename` (kebab-case via existing path) are unchanged, except the kebab path now prefers fast model when available and runs its output through `stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the sentence-case path). New shared util: `packages/core/src/utils/terminalSafe.ts` — `stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI (\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise execute on every SessionPicker render; both sentence-case and kebab paths now route titles through the helper before they reach the JSONL or the UI. Tail-read extractor: `extractLastJsonStringFields(text, primaryKey, otherKeys, lineContains)` reads multiple fields from the same matching line in a single pass. Two separate tail scans could return a mismatched pair (primary from a newer record, secondary from an older one with only the primary set); the new helper guarantees the pair is atomic. Validates a proper closing quote on the primary value so a crash-truncated trailing record can't win the latest-match race. `readLastJsonStringFieldsSync` is its file-reading wrapper — same tail-window fast path and full-file fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB` cap so a corrupt multi-GB session file can't freeze the picker. Session reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows where the constant isn't exposed) — defense in depth against a symlink planted in `~/.qwen/projects/<proj>/chats/`. Character handling: `flattenToTail` on the LLM prompt drops a dangling low surrogate after `slice(-1000)` — otherwise a CJK supplementary char or emoji cut mid-pair produces invalid UTF-16 that some providers 400. `sanitizeTitle` applies the same surrogate scrub after max-length trim, and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char strip. `lineContains` in the title reader is tightened from the loose substring `'custom_title'` to `'"subtype":"custom_title"'` so user text containing the literal `custom_title` can't shadow a real record. Tests: 46 new unit tests across - `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets. - `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix, in-flight guard, abort propagation on finalize, manual/auto/legacy resume symmetry, cross-process race, env opt-out, retry-after- transient. - `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle boundary, truncated trailing record, lineContains, multi-field atom. - `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel, unknown-flag hint, positional rejection, manual/SessionService fallbacks. * docs(session): design doc for auto session titles Matches the session-recap design doc shape (Overview / Triggers / Architecture / Prompt Design / History Filtering / Persistence / Concurrency / Configuration / Observability / Out of Scope) and adds a Security Hardening section unique to the title path — titles render directly in the picker and persist in user-readable JSONL, so LLM-returned control sequences are an attack surface the recap path doesn't have. Captures decisions a code-only reader has to reverse-engineer: - Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop). - Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call). - Why the auto trigger does NOT fall back to the main model but session-recap does (auto-title fires on every turn; silently charging main-model tokens is a bill surprise). - Why `titleSource: undefined` stays unwritten on legacy records (no rewrite risks silently reclassifying user intent). - Why the cross-process re-read sits between the LLM await and the append (manual wins at both in-process and on-disk layers). - Why `finalize()`'s abort tolerates a controller swap (in-flight identity check). - Why JSON-schema function calling instead of tag extraction (avoid reasoning preamble bleed; cross-provider reliability). Placed at docs/design/session-title/ alongside session-recap, compact-mode, fork-subagent, and other per-feature design docs. No sidebar index update required — the design folder is unindexed. * test(rename): pin model choice in bare /rename kebab path Addresses reviewer feedback: the bare `/rename` model selection (`config.getFastModel() ?? config.getModel()`) had no test pinning it either way. Previous tests mocked `getHistory: []`, which exits the function before the model is ever chosen, so a silent regression to either direction (always-main or always-fast) would pass CI. Two explicit cases now: - fastModel set → `generateContent` called with `model: 'qwen-turbo'`. - fastModel unset → `generateContent` called with `model: 'main-model'`. The tests intentionally mock a non-empty history so the kebab path reaches the generateContent call site instead of bailing on empty input.
TLDR
Auto-generate a 3-7 word sentence-case session title via the fast model after the first assistant turn. Persist as
titleSource: 'auto'(distinguished from manual in the picker via dimmed color). Adds/rename --autofor user-triggered regeneration. Builds on #3093.Where to start reviewing
The feature is ~280 lines; the rest is persistence plumbing and hardening. Suggested order:
packages/core/src/services/sessionTitle.ts— the generator (prompt + schema + sanitize).packages/core/src/services/chatRecordingService.ts—maybeTriggerAutoTitle(the 6 guards + fire-and-forget IIFE) and the resume hydration in the constructor.packages/cli/src/ui/commands/renameCommand.ts—/rename --autoUX and failure-reason mapping.terminalSafe.ts, new extractorreadLastJsonStringFieldsSync,SessionPicker1-line dim conditional, tests).Risk / compatibility
custom_titlerecords (notitleSourcefield) are treated asundefined→ rendered as manual. No migration, no rewrite. A user's/renamefrom a prior version is never silently reclassified.CustomTitleRecordPayload.titleSourceis an optional additive field.QWEN_DISABLE_AUTO_TITLE=1disables the trigger without touchingfastModel(so/rename --autostill works on explicit request).qwen -p "..."and CI runs never spend fast-model tokens on a one-shot session.config.getFastModel()is falsy we skip entirely. We do NOT silently fall back to the main model (too expensive to be silent)./rename <name>(explicit name) unchanged: writes the literal title astitleSource: 'manual'./rename(no args) has one behavior change: the kebab-case generator now prefersfastModelover the main model (config.getFastModel() ?? config.getModel()), instead of always using the main model. Output is also run throughstripTerminalControlSequences. Users withoutfastModelconfigured see no change; users withfastModelconfigured get a faster and cheaper call but potentially weaker on mixed-language summarization. Pinned by new tests inrenameCommand.test.ts→describe('bare /rename model selection'). To revert to the main model only, either unsetfastModelentirely (affects other features) or use/rename <name>with an explicit name.Dive Deeper
Trigger guards (in order)
currentCustomTitleset — never overwrite manual / prior auto.autoTitleController !== undefined— one attempt at a time.autoTitleAttempts < 3— cap prevents runaway retry on persistent failure. Retry across turns is needed because a first assistant turn can be pure tool-call with no user-visible text.config.isInteractive().!autoTitleDisabledByEnv().config.getFastModel().Persistence
CustomTitleRecordPayloadgrowstitleSource: 'auto' | 'manual'. On resume,ChatRecordingServicerehydrates bothcurrentCustomTitleANDcurrentTitleSourcevia the newSessionService.getSessionTitleInfo. Without this,finalize()'s re-append would rewrite auto as manual on every resume — a silent downgrade.Cross-process safety
Two CLI tabs on the same JSONL can diverge in memory. Before appending an auto record, the IIFE re-reads the file via
getSessionTitleInfo. If the file already showssource: 'manual'(another process's/renamewon), bail and sync in-memory state. Cost: one 64KB tail read per successful generation./rename --autoExplicit user regeneration — overwrites any existing title. Failure reasons map to actionable messages (no fast model, empty history, empty result, model error, aborted) rather than a generic "could not generate".
/rename -- --literal-nameis the sentinel for titles starting with--.Defense in depth (one line each)
stripTerminalControlSequencesutil strips OSC-8 / CSI / SS2-3 / C0-C1-DEL. Both/rename --autoand bare/renameroute through it before the JSONL or UI.extractLastJsonStringFieldsrequires a proper closing quote so a crash-truncated trailing record can't win the latest-match race.customTitleandtitleSourcecome from the same line.O_NOFOLLOW(Windows no-op).【Draft】 Fix loginhas the paired bracket stripped as a unit.lineContainstightening — matcher upgraded from substring'custom_title'to'"subtype":"custom_title"'so user text containing the literal phrase can't shadow a real record.End-to-end evidence (tmux captures from a live local CLI)
All of the following are verbatim captures against the built branch on Linux with a working
fastModelconfigured.1. Auto-title trigger fires after the first assistant turn
Sent:
help me understand the login button bug on mobile. Assistant replied, then the background IIFE hit the fast model. After exit, the session JSONL shows the new system record:{"uuid":"64a5703c-7283-437b-b6b3-d0acee156323", "type":"system","subtype":"custom_title", "systemPayload":{"customTitle":"Debug login button on mobile", "titleSource":"auto"}}3-7 words, sentence case,
titleSource: auto. No othercustom_titlerecords in the file.2.
/rename --autoregenerates on demand (overwrites existing auto)Terminal capture:
JSONL now has TWO
custom_titlerecords (latest wins); model picked Chinese because the conversation had switched languages — the prompt explicitly tells the model to match the dominant language.{ ... "customTitle":"Debug login button on mobile","titleSource":"auto" } { ... "customTitle":"排查移动端登录按钮问题","titleSource":"auto" }3. Unknown
--flagshows the sentinel hint4.
/rename -- --literal-namesentinel lets dashes throughJSONL:
"customTitle":"--my-literal-name","titleSource":"manual".5.
/rename --auto some-nameis rejected6.
QWEN_DISABLE_AUTO_TITLE=1skips the trigger entirelySame prompt as test 1 but with the env var set. After exit:
Main conversation is unaffected (assistant reply still recorded,
2+2→6etc.).7. Error path with a broken fast model
When the configured fastModel is unreachable (e.g., belongs to a different provider), the auto-title code path still runs but fails cleanly:
[WARN] [SESSION_TITLE] Session title generation failed: Failed to generate JSON content: 502 unknown provider for model qwen3.5-flashcustom_titlerecord is written (no dirty data on disk)./rename --autosurfaces a user-visible error:✕ The fast model could not generate a title (rate limit, auth, or network error). Check debug log or try again.Stack trace from the debug log confirms the call went through
tryGenerateSessionTitle→BaseLlmClient.generateJson→retryWithBackoff→ OpenAI pipeline, and thatmaxAttempts: 1short-circuited the retry loop.8. Session picker renders the auto title
After test 2,
/resumeshows:When selected, accent color takes priority (legibility first); when non-selected,
theme.text.secondaryis applied by theisAutoTitlebranch inSessionPicker.tsx.Reviewer Test Plan
"fastModel": "<available-model-id>"in~/.qwen/settings.json.cd /tmp/test-autotitle && qwen→ send a real prompt → wait for the reply.titleSource: "auto"and a 3-7 word sentence-case title.qwen --resumeand select the session — the auto title should render dimmer than non-selected manual titles (if any)./rename my-manual→ verify a new record appears withtitleSource: "manual"; picker no longer dims it./rename --auto→ verify regeneration overwrites back totitleSource: "auto"./rename --my-flag→ expect the sentinel-hint error message.QWEN_DISABLE_AUTO_TITLE=1 qwen→ nocustom_titlerecord should appear.Testing Matrix
Manually verified on Linux via tmux end-to-end (captures above). Unit tests (6054 core + 15 rename CLI) pass; CI should cover macOS/Windows.
Linked issues / bugs
Builds on #3093.