Commit graph

2 commits

Author SHA1 Message Date
Shaojin Wen
d36f12c4c4
feat(session): auto-title sessions via fast model, add /rename --auto (#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.
2026-04-23 20:37:05 +08:00
qqqys
0c423deedf
feat(session): add rename, delete, and auto-title generation for session (#3093)
* feat(session): add rename, delete, and auto-title generation for sessions

- Add /rename command with LLM auto-title generation when no args provided
- Add /delete command to remove sessions from the session picker
- Display session name tag embedded in input prompt top border
- Restore session name on /resume and --resume <title> CLI flag
- Support rename and delete via ACP extMethod for VSCode extension
- Add rename/delete UI to WebUI SessionSelector with two-click delete confirmation
- Fix parentUuid chain: custom_title records now correctly reference the
  previous record's UUID, preventing session history from appearing empty
  after rename
- Add SESSION_FILE_PATTERN validation to all SessionService methods that
  construct file paths from sessionId (defense-in-depth against path traversal)
- Fix fd leak in readCustomTitleFromFile with try/finally
- Fix --resume <title> exit code (exit 1 when no match found)
- Add project ownership checks to VSCode qwenSessionReader delete/rename

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(session): fix broken imports and missing mocks from rename/auto-title feature

- Fix renameCommand.ts import path to use barrel export instead of deep path
- Add setSessionName to mock CommandContext
- Add getSessionTitle to SessionService mock in useResumeCommand tests
- Update renameCommand tests for auto-generate title behavior
- Update InputPrompt snapshots

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(session): add head+tail dual-read, string-level extraction, and finalize mechanism

- Add sessionStorageUtils with extractLastJsonStringField() for fast
  string-level JSON field extraction without full parse
- Add readHeadAndTailSync() to read first and last 64KB of session files
- Replace readCustomTitleFromFile() with readSessionTitleFromFile() using
  head+tail dual-read (tail customTitle > head customTitle)
- Add finalize() to ChatRecordingService as single entry point for
  re-appending session metadata on any session departure
- Call finalize() on resume, session switch, and shutdown
- Export sessionStorageUtils from core package

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(session): show filtered picker when /resume <title> matches multiple sessions

Previously, multiple title matches opened the full session picker,
forcing the user to re-find their session. Now the matched sessions
are passed through as initialSessions to the picker, skipping the
full listSessions() load and showing only the relevant results.

Also clears sessionName on /clear so new sessions don't carry stale
title tags from the previous session.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ui): use stringWidth for CJK-safe border alignment in input prompt

topRightLabel.length counts UTF-16 code units, not terminal columns.
CJK characters take 2 columns but .length returns 1, causing the
border line to overflow. Use string-width for correct display width.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(session): address remaining PR #3093 review feedback

- Add SESSION_TITLE_MAX_LENGTH shared constant in core, replace
  hardcoded 200 in CLI/ACP/VSCode/WebUI
- Add title length validation to ACP renameSession endpoint
- Make recordCustomTitle return boolean; renameCommand checks it
  before updating UI to prevent silent data loss
- Add gitBranch to VSCode rename record for consistency with CLI
- Remove misleading "enforce kebab-case" comment
- Remove duplicate JSDoc on topRightLabel

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ui): add animated dots to session name generation loading indicator

The static "Generating session name…" text gave no visual feedback that
the operation was in progress. Cycle through ".", "..", "..." every
500ms so users can tell the LLM call is still running.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* feat(cli): add /tag as alias for /rename command

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(vscode): add loading overlay when switching to historical conversations

Adds isSwitchingSession state and sessionLoadComplete message to show
a loading transition while session history is being rehydrated via ACP.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(vscode): add 15s timeout fallback for session switching loading state

Prevents loading overlay from getting stuck indefinitely if
sessionLoadComplete message is never received.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(core): fix extractLastJsonStringField offset tracking and add lineContains filter

1. Track global character offset across both pattern variants so the
   truly last match wins (previously the second pattern scan could
   overwrite a later match from the first pattern).

2. Add optional lineContains parameter to scope matches to lines
   containing a marker (e.g. "custom_title"), preventing false matches
   from user content that happens to include a "customTitle" field.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* chore(cli): add i18n import to DialogManager

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* fix(vscode): align currentConversationId with webview on fallback restore

When session/load falls back to creating a fresh ACP session, backend
was tracking the new ACP id while the webview still viewed the archived
sessionId. That desync caused delete/rename/title-update to target the
wrong session during the fallback window, and prevented the post-first-
message sync path from firing because the two ids were pre-aligned.

Keep currentConversationId pointing at the archived sessionId until the
existing stream-end sync flips both sides to the live ACP id on the
first user message. Matches the pattern already used by the offline
branch.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(core): exhaustive scan in findSessionsByTitle to avoid mtime-boundary misses

listSessions() paginates with an mtime-only cursor and strict `<`
filter. When several session files share the same mtime across a page
boundary, the next page's filter drops them, so --resume <title> could
silently miss valid matches.

Scan all session files directly for title lookup, with filename as a
stable tie-breaker. Also check the (cheap) custom title before the
full hydration pass (first-record read, project filter, message count,
prompt extraction) so non-matching sessions skip the extra I/O.

listSessions() itself is left alone: its cursor crosses ACP/webview
package boundaries as a number and this edge case only affects UI
display order, not data loss.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(acp): plumb listSessions page size through _meta

VSCode companion passes `size` to acpConnection.listSessions, but the
ACP spec's ListSessionsRequest schema has no `size` field, so the SDK's
zod validator strips it before the agent handler sees it. The agent
then only forwarded `cursor` to SessionService.listSessions, silently
ignoring the caller's page-size intent.

Carry page size through `_meta.size` on both sides, matching the
pattern already used for other Qwen Code ACP extensions (e.g. the
filesystem service's `_meta.bom` / `_meta.encoding`). `_meta` is typed
as an open record in the ACP schema, so extra keys survive validation.

Co-Authored-By: Qwen-Coder <noreply@qwen.com>

* fix(webui): avoid unintended rename when canceling with Escape

The rename input auto-submits on blur, and pressing Escape also triggers
blur (via setRenamingSessionId(null) unmounting the input). Because state
updates are async, the blur handler's handleRenameSubmit could still read
the pre-Escape renameValue from its closure and call onRenameSession,
turning a cancel into an accidental rename.

Track cancellation via an isCancelingRenameRef flag: set it in the Escape
branch, and have onBlur short-circuit when the flag is true, then reset it.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(vscode-ide-companion): clear switch timeout on unmount

The 15s session-switch fallback timer was only cleared on the next call
to setIsSwitchingSession. If the webview is torn down mid-switch, the
timer stays alive and later fires setIsSwitchingSessionRaw(false) on an
unmounted hook. Add a useEffect cleanup to clear any pending timer on
unmount.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(cli): break handleResume/slashCommandActions circular dep

slashCommandActions (useMemo) depends on handleResume, but handleResume
was declared after useSlashCommandProcessor so it could call
setAwayRecapItem(null). useSlashCommandProcessor itself consumes
slashCommandActions, closing a three-way cycle that tsc catches as
TS2448 "used before declaration" once #3478's AppContainer changes land
in main and get auto-merged into open PRs.

Move handleResume above slashCommandActions and route the recap clear
through a ref that a later useEffect syncs with setAwayRecapItem.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

* fix(core): scan full file when title is not in tail window

Replace the head+tail dual-read with readLastJsonStringFieldSync: scan the
tail first and return on hit, otherwise stream the whole file and return
the last match. Closes the blind spot where a custom_title record landing
between the head and tail windows would be missed on large session files.

Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <noreply@qwen.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <noreply@qwen.ai>
Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
2026-04-22 11:48:01 +08:00