mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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.
This commit is contained in:
parent
d14ce16b95
commit
d36f12c4c4
16 changed files with 2864 additions and 53 deletions
376
docs/design/session-title/session-title-design.md
Normal file
376
docs/design/session-title/session-title-design.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# 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 <name> → 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/<proj>/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/<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.
|
||||
|
||||
## 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. |
|
||||
|
|
@ -9,16 +9,31 @@ import { renameCommand } from './renameCommand.js';
|
|||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
const tryGenerateSessionTitleMock = vi.fn();
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
(await importOriginal()) as typeof import('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...original,
|
||||
tryGenerateSessionTitle: (...args: unknown[]) =>
|
||||
tryGenerateSessionTitleMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('renameCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
tryGenerateSessionTitleMock.mockReset();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(renameCommand.name).toBe('rename');
|
||||
expect(renameCommand.description).toBe('Rename the current conversation');
|
||||
expect(renameCommand.description).toBe(
|
||||
'Rename the current conversation. --auto lets the fast model pick a title.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when config is not available', async () => {
|
||||
|
|
@ -103,7 +118,7 @@ describe('renameCommand', () => {
|
|||
|
||||
const result = await renameCommand.action!(mockContext, 'my-feature');
|
||||
|
||||
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature');
|
||||
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature', 'manual');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
|
|
@ -130,6 +145,7 @@ describe('renameCommand', () => {
|
|||
expect(mockRenameSession).toHaveBeenCalledWith(
|
||||
'test-session-id',
|
||||
'my-feature',
|
||||
'manual',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
|
|
@ -159,4 +175,270 @@ describe('renameCommand', () => {
|
|||
content: 'Failed to rename session.',
|
||||
});
|
||||
});
|
||||
|
||||
describe('bare /rename model selection', () => {
|
||||
// Pins the kebab-case path's model choice: bare `/rename` (no args)
|
||||
// prefers fastModel when one is configured, falls back to the main
|
||||
// model otherwise. Previous tests mocked `getHistory: []` which bailed
|
||||
// before the model selection ran, leaving this regression-prone.
|
||||
function mockConfigForKebab(opts: { fastModel?: string; model?: string }): {
|
||||
config: unknown;
|
||||
generateContent: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const generateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'fix-login-bug' }] } }],
|
||||
});
|
||||
const config = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue(opts.fastModel),
|
||||
getModel: vi.fn().mockReturnValue(opts.model ?? 'main-model'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
getHistory: vi.fn().mockReturnValue([
|
||||
{ role: 'user', parts: [{ text: 'fix the login bug' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Looking at the handler now.' }],
|
||||
},
|
||||
]),
|
||||
}),
|
||||
getContentGenerator: vi.fn().mockReturnValue({ generateContent }),
|
||||
};
|
||||
return { config, generateContent };
|
||||
}
|
||||
|
||||
it('uses fastModel when configured', async () => {
|
||||
const { config, generateContent } = mockConfigForKebab({
|
||||
fastModel: 'qwen-turbo',
|
||||
model: 'main-model',
|
||||
});
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: config as never },
|
||||
});
|
||||
|
||||
await renameCommand.action!(mockContext, '');
|
||||
|
||||
expect(generateContent).toHaveBeenCalledOnce();
|
||||
expect(generateContent.mock.calls[0][0].model).toBe('qwen-turbo');
|
||||
});
|
||||
|
||||
it('falls back to main model when fastModel is unset', async () => {
|
||||
const { config, generateContent } = mockConfigForKebab({
|
||||
fastModel: undefined,
|
||||
model: 'main-model',
|
||||
});
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: config as never },
|
||||
});
|
||||
|
||||
await renameCommand.action!(mockContext, '');
|
||||
|
||||
expect(generateContent).toHaveBeenCalledOnce();
|
||||
expect(generateContent.mock.calls[0][0].model).toBe('main-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--auto flag', () => {
|
||||
it('refuses --auto when no fast model is configured', async () => {
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: vi.fn(),
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '--auto');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
|
||||
});
|
||||
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses --auto combined with a positional name', async () => {
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: vi.fn(),
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '--auto my-name');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
|
||||
});
|
||||
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes an auto-sourced title on --auto success', async () => {
|
||||
tryGenerateSessionTitleMock.mockResolvedValue({
|
||||
ok: true,
|
||||
title: 'Fix login button on mobile',
|
||||
modelUsed: 'qwen-turbo',
|
||||
});
|
||||
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: mockRecordCustomTitle,
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '--auto');
|
||||
|
||||
expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
|
||||
expect(mockRecordCustomTitle).toHaveBeenCalledWith(
|
||||
'Fix login button on mobile',
|
||||
'auto',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Session renamed to "Fix login button on mobile"',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces empty_history reason with actionable hint', async () => {
|
||||
tryGenerateSessionTitleMock.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'empty_history',
|
||||
});
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: vi.fn(),
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '--auto');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'No conversation to title yet — send at least one message first.',
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces model_error reason distinctly', async () => {
|
||||
tryGenerateSessionTitleMock.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'model_error',
|
||||
});
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: vi.fn(),
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '--auto');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
messageType: 'error',
|
||||
});
|
||||
expect((result as { content: string }).content).toMatch(
|
||||
/rate limit, auth, or network error/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown flag with sentinel hint', async () => {
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: vi.fn(),
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(
|
||||
mockContext,
|
||||
'--my-label-with-dashes',
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ messageType: 'error' });
|
||||
const content = (result as { content: string }).content;
|
||||
expect(content).toMatch(/Unknown flag "--my-label-with-dashes"/);
|
||||
expect(content).toMatch(/\/rename -- --my-label-with-dashes/);
|
||||
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces aborted reason when user cancels', async () => {
|
||||
tryGenerateSessionTitleMock.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'aborted',
|
||||
});
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: vi.fn(),
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '--auto');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Title generation was cancelled.',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to SessionService.renameSession with auto source', async () => {
|
||||
tryGenerateSessionTitleMock.mockResolvedValue({
|
||||
ok: true,
|
||||
title: 'Audit auth middleware',
|
||||
modelUsed: 'qwen-turbo',
|
||||
});
|
||||
const mockRenameSession = vi.fn().mockResolvedValue(true);
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getSessionService: vi.fn().mockReturnValue({
|
||||
renameSession: mockRenameSession,
|
||||
}),
|
||||
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '--auto');
|
||||
|
||||
expect(mockRenameSession).toHaveBeenCalledWith(
|
||||
'test-session-id',
|
||||
'Audit auth middleware',
|
||||
'auto',
|
||||
);
|
||||
expect(result).toMatchObject({ messageType: 'info' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@
|
|||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
getResponseText,
|
||||
SESSION_TITLE_MAX_LENGTH,
|
||||
stripTerminalControlSequences,
|
||||
tryGenerateSessionTitle,
|
||||
type Config,
|
||||
type SessionTitleFailureReason,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
|
@ -38,9 +41,11 @@ function extractConversationText(history: Content[]): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Calls the LLM to generate a short session title from conversation history.
|
||||
* Calls the LLM to generate a short kebab-case session title from conversation
|
||||
* history. Used when `/rename` is invoked with no arguments — produces a
|
||||
* filesystem-style name for sessions the user wants to keep long-term.
|
||||
*/
|
||||
async function generateSessionTitle(
|
||||
async function generateKebabTitle(
|
||||
config: Config,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
|
|
@ -51,9 +56,15 @@ async function generateSessionTitle(
|
|||
return null;
|
||||
}
|
||||
|
||||
// Prefer the fast model for title generation — it's much cheaper and
|
||||
// faster than the main model, and title generation is a small bounded
|
||||
// task that doesn't need main-model reasoning. Falls back to the main
|
||||
// model when no fast model is configured so this path never fails to
|
||||
// start.
|
||||
const model = config.getFastModel() ?? config.getModel();
|
||||
const response = await config.getContentGenerator().generateContent(
|
||||
{
|
||||
model: config.getModel(),
|
||||
model,
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
|
|
@ -79,8 +90,13 @@ async function generateSessionTitle(
|
|||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
// Clean up: take first line, remove quotes/backticks
|
||||
const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim();
|
||||
// Clean up: strip ANSI / control sequences via the shared helper
|
||||
// (same security concern as the sentence-case path — the title renders
|
||||
// directly in the picker), then take the first line and drop quotes.
|
||||
const cleaned = stripTerminalControlSequences(text)
|
||||
.split('\n')[0]
|
||||
.replace(/["`']/g, '')
|
||||
.trim();
|
||||
return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH
|
||||
? cleaned
|
||||
: null;
|
||||
|
|
@ -89,12 +105,88 @@ async function generateSessionTitle(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a title-generation failure reason into a human-actionable
|
||||
* message. Exists so `/rename --auto` doesn't collapse to a generic "could
|
||||
* not generate" that leaves the user guessing about the cause.
|
||||
*/
|
||||
function autoFailureMessage(reason: SessionTitleFailureReason): string {
|
||||
switch (reason) {
|
||||
case 'no_fast_model':
|
||||
return t(
|
||||
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
|
||||
);
|
||||
case 'empty_history':
|
||||
return t(
|
||||
'No conversation to title yet — send at least one message first.',
|
||||
);
|
||||
case 'empty_result':
|
||||
return t(
|
||||
'The fast model returned no usable title. Try `/rename <name>` to set one yourself.',
|
||||
);
|
||||
case 'aborted':
|
||||
return t('Title generation was cancelled.');
|
||||
case 'model_error':
|
||||
return t(
|
||||
'The fast model could not generate a title (rate limit, auth, or network error). Check debug log or try again.',
|
||||
);
|
||||
case 'no_client':
|
||||
return t('Session is still initializing — try again in a moment.');
|
||||
default:
|
||||
return t('Could not generate a title.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `--auto` out of the args. Kept simple rather than bringing in an
|
||||
* argv parser — we only have one flag.
|
||||
*
|
||||
* Rules:
|
||||
* - `--auto` (case-insensitive) sets auto=true.
|
||||
* - `--` terminates flag parsing; everything after is positional, so users
|
||||
* can legitimately name sessions starting with `--` via `/rename -- --foo`.
|
||||
* - Any other `--xxx` before `--` bubbles up as `unknownFlag` for a clean
|
||||
* error, rather than silently becoming part of the title (`--Auto` typo,
|
||||
* `--help` expectation, etc.).
|
||||
*/
|
||||
function parseArgs(raw: string): {
|
||||
auto: boolean;
|
||||
positional: string;
|
||||
unknownFlag?: string;
|
||||
} {
|
||||
const trimmed = raw.trim().replace(/[\r\n]+/g, ' ');
|
||||
if (!trimmed) return { auto: false, positional: '' };
|
||||
const parts = trimmed.split(/\s+/);
|
||||
let auto = false;
|
||||
let unknownFlag: string | undefined;
|
||||
let flagsDone = false;
|
||||
const rest: string[] = [];
|
||||
for (const p of parts) {
|
||||
if (!flagsDone && p === '--') {
|
||||
flagsDone = true;
|
||||
continue;
|
||||
}
|
||||
if (!flagsDone && p.startsWith('--')) {
|
||||
if (p.toLowerCase() === '--auto') {
|
||||
auto = true;
|
||||
continue;
|
||||
}
|
||||
if (!unknownFlag) unknownFlag = p;
|
||||
continue;
|
||||
}
|
||||
rest.push(p);
|
||||
}
|
||||
return { auto, positional: rest.join(' '), unknownFlag };
|
||||
}
|
||||
|
||||
export const renameCommand: SlashCommand = {
|
||||
name: 'rename',
|
||||
altNames: ['tag'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
get description() {
|
||||
return t('Rename the current conversation');
|
||||
return t(
|
||||
'Rename the current conversation. --auto lets the fast model pick a title.',
|
||||
);
|
||||
},
|
||||
action: async (context, args): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
|
|
@ -107,10 +199,87 @@ export const renameCommand: SlashCommand = {
|
|||
};
|
||||
}
|
||||
|
||||
let name = args.trim().replace(/[\r\n]+/g, ' ');
|
||||
const { auto, positional, unknownFlag } = parseArgs(args);
|
||||
if (unknownFlag) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Unknown flag "{{flag}}". Supported: --auto. To use this as a literal name, run `/rename -- {{flag}}`.',
|
||||
{ flag: unknownFlag },
|
||||
),
|
||||
};
|
||||
}
|
||||
let name = positional;
|
||||
// Track where the title came from so the session picker can dim
|
||||
// auto-generated titles; explicit user text stays 'manual'.
|
||||
let titleSource: 'auto' | 'manual' = 'manual';
|
||||
|
||||
// If no name provided, auto-generate one from conversation history
|
||||
if (!name) {
|
||||
if (auto) {
|
||||
// Explicit user-triggered auto-title. This overwrites whatever title
|
||||
// is currently set (manual or auto) because the user asked for it.
|
||||
// Requires a configured fast model — we don't silently fall back to
|
||||
// the main model here because `--auto` is a deliberate opt-in to the
|
||||
// sentence-case fast-model flow, and surprising a user with a main-
|
||||
// model call would defeat the purpose.
|
||||
if (!config.getFastModel()) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
|
||||
),
|
||||
};
|
||||
}
|
||||
if (positional) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
|
||||
),
|
||||
};
|
||||
}
|
||||
const dots = ['.', '..', '...'];
|
||||
let dotIndex = 0;
|
||||
const baseText = t('Regenerating session title');
|
||||
context.ui.setPendingItem({
|
||||
type: 'info',
|
||||
text: baseText + dots[dotIndex],
|
||||
});
|
||||
const timer = setInterval(() => {
|
||||
dotIndex = (dotIndex + 1) % dots.length;
|
||||
context.ui.setPendingItem({
|
||||
type: 'info',
|
||||
text: baseText + dots[dotIndex],
|
||||
});
|
||||
}, 500);
|
||||
// try/finally ensures the spinner stops even if tryGenerateSessionTitle
|
||||
// ever throws (it currently swallows internally, but defensively so
|
||||
// future regressions don't leak an interval timer).
|
||||
let outcome: Awaited<ReturnType<typeof tryGenerateSessionTitle>>;
|
||||
try {
|
||||
outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
context.abortSignal ?? new AbortController().signal,
|
||||
);
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
context.ui.setPendingItem(null);
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: autoFailureMessage(outcome.reason),
|
||||
};
|
||||
}
|
||||
name = outcome.title;
|
||||
titleSource = 'auto';
|
||||
} else if (!name) {
|
||||
// Legacy no-arg behavior: kebab-case, generated via the main content
|
||||
// generator with fallback to fastModel. Preserved as-is for users who
|
||||
// prefer filesystem-style names.
|
||||
const dots = ['.', '..', '...'];
|
||||
let dotIndex = 0;
|
||||
const baseText = t('Generating session name');
|
||||
|
|
@ -125,9 +294,13 @@ export const renameCommand: SlashCommand = {
|
|||
text: baseText + dots[dotIndex],
|
||||
});
|
||||
}, 500);
|
||||
const generated = await generateSessionTitle(config, context.abortSignal);
|
||||
clearInterval(timer);
|
||||
context.ui.setPendingItem(null);
|
||||
let generated: string | null;
|
||||
try {
|
||||
generated = await generateKebabTitle(config, context.abortSignal);
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
context.ui.setPendingItem(null);
|
||||
}
|
||||
if (!generated) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
|
@ -151,7 +324,7 @@ export const renameCommand: SlashCommand = {
|
|||
// Record the custom title in the current session's JSONL file
|
||||
const chatRecordingService = config.getChatRecordingService();
|
||||
if (chatRecordingService) {
|
||||
const ok = chatRecordingService.recordCustomTitle(name);
|
||||
const ok = chatRecordingService.recordCustomTitle(name, titleSource);
|
||||
if (!ok) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
|
@ -163,7 +336,11 @@ export const renameCommand: SlashCommand = {
|
|||
// Fallback: write via SessionService for non-recording sessions
|
||||
const sessionId = config.getSessionId();
|
||||
const sessionService = config.getSessionService();
|
||||
const success = await sessionService.renameSession(sessionId, name);
|
||||
const success = await sessionService.renameSession(
|
||||
sessionId,
|
||||
name,
|
||||
titleSource,
|
||||
);
|
||||
if (!success) {
|
||||
return {
|
||||
type: 'message',
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ function SessionListItemView({
|
|||
|
||||
const promptText = session.customTitle || session.prompt || '(empty prompt)';
|
||||
const truncatedPrompt = truncateText(promptText, maxPromptWidth);
|
||||
// Dim auto-generated titles so users can distinguish a model guess from
|
||||
// a title they chose themselves with `/rename`. Selected row keeps the
|
||||
// accent color — legibility of the focused row wins over source hinting.
|
||||
const isAutoTitle =
|
||||
session.titleSource === 'auto' && Boolean(session.customTitle);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={isLast ? 0 : 1}>
|
||||
|
|
@ -111,7 +116,13 @@ function SessionListItemView({
|
|||
{prefix}
|
||||
</Text>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
color={
|
||||
isSelected
|
||||
? theme.text.accent
|
||||
: isAutoTitle
|
||||
? theme.text.secondary
|
||||
: theme.text.primary
|
||||
}
|
||||
bold={isSelected}
|
||||
>
|
||||
{truncatedPrompt}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ export * from './services/gitService.js';
|
|||
export * from './services/gitWorktreeService.js';
|
||||
export * from './services/sessionRecap.js';
|
||||
export * from './services/sessionService.js';
|
||||
export * from './services/sessionTitle.js';
|
||||
export { stripTerminalControlSequences } from './utils/terminalSafe.js';
|
||||
export * from './services/shellExecutionService.js';
|
||||
export * from './utils/bareMode.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,508 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
ChatRecordingService,
|
||||
type ChatRecord,
|
||||
} from './chatRecordingService.js';
|
||||
import * as jsonl from '../utils/jsonl-utils.js';
|
||||
|
||||
const tryGenerateSessionTitleMock = vi.fn();
|
||||
|
||||
vi.mock('./sessionTitle.js', () => ({
|
||||
tryGenerateSessionTitle: (...args: unknown[]) =>
|
||||
tryGenerateSessionTitleMock(...args),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Most tests assert on the success-outcome path: this helper wraps
|
||||
* `{title, modelUsed}` in the new `{ok: true, ...}` shape so we don't have
|
||||
* to repeat it everywhere. Failure outcomes are spelled out where they
|
||||
* exercise distinct reasons.
|
||||
*/
|
||||
function mockOk(title: string, modelUsed = 'qwen-turbo'): void {
|
||||
tryGenerateSessionTitleMock.mockResolvedValue({
|
||||
ok: true,
|
||||
title,
|
||||
modelUsed,
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('node:path');
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: vi.fn(),
|
||||
createHash: vi.fn(() => ({
|
||||
update: vi.fn(() => ({
|
||||
digest: vi.fn(() => 'mocked-hash'),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../utils/jsonl-utils.js');
|
||||
|
||||
/**
|
||||
* Let the fire-and-forget auto-title promise kicked off by
|
||||
* `recordAssistantTurn` settle. The IIFE awaits the generation mock, which
|
||||
* adds at least one microtask hop; a single `Promise.resolve()` flush isn't
|
||||
* always enough. A setImmediate boundary after several microtask ticks
|
||||
* covers pathological cases where a mock resolves via a deeper await chain.
|
||||
*/
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function findCustomTitleRecord(): ChatRecord | undefined {
|
||||
return vi
|
||||
.mocked(jsonl.writeLineSync)
|
||||
.mock.calls.map((c) => c[1] as ChatRecord)
|
||||
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
|
||||
}
|
||||
|
||||
describe('ChatRecordingService - auto-title trigger', () => {
|
||||
let chatRecordingService: ChatRecordingService;
|
||||
let mockConfig: Config;
|
||||
let fastModelValue: string | undefined;
|
||||
let uuidCounter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
uuidCounter = 0;
|
||||
fastModelValue = 'qwen-turbo';
|
||||
tryGenerateSessionTitleMock.mockReset();
|
||||
|
||||
mockConfig = {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
storage: {
|
||||
getProjectTempDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/test/project/root/.qwen/tmp/hash'),
|
||||
getProjectDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/test/project/root/.qwen/projects/test-project'),
|
||||
},
|
||||
getModel: vi.fn().mockReturnValue('qwen-plus'),
|
||||
getFastModel: vi.fn(() => fastModelValue),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn().mockReturnValue({
|
||||
displayName: 'Test Tool',
|
||||
description: 'A test tool',
|
||||
isOutputMarkdown: false,
|
||||
}),
|
||||
}),
|
||||
getResumedSessionData: vi.fn().mockReturnValue(undefined),
|
||||
// Default SessionService for the cross-process re-read: returns no
|
||||
// title, i.e. "nothing else has landed on disk" — tests that need
|
||||
// a specific on-disk state override this mock.
|
||||
getSessionService: vi.fn().mockReturnValue({
|
||||
getSessionTitleInfo: vi.fn().mockReturnValue({}),
|
||||
getSessionTitle: vi.fn().mockReturnValue(undefined),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(randomUUID).mockImplementation(
|
||||
() =>
|
||||
`00000000-0000-0000-0000-00000000000${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`,
|
||||
);
|
||||
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
|
||||
vi.mocked(path.dirname).mockImplementation((p) => {
|
||||
const parts = p.split('/');
|
||||
parts.pop();
|
||||
return parts.join('/');
|
||||
});
|
||||
vi.mocked(execSync).mockReturnValue('main\n');
|
||||
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined);
|
||||
vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
|
||||
chatRecordingService = new ChatRecordingService(mockConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('writes an auto-sourced title after the first assistant turn', async () => {
|
||||
mockOk('Fix login button');
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'Looking at the button handler now.' }],
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
|
||||
const titleRecord = findCustomTitleRecord();
|
||||
expect(titleRecord).toBeDefined();
|
||||
expect(titleRecord?.systemPayload).toEqual({
|
||||
customTitle: 'Fix login button',
|
||||
titleSource: 'auto',
|
||||
});
|
||||
expect(chatRecordingService.getCurrentTitleSource()).toBe('auto');
|
||||
expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not trigger when no fast model is configured', async () => {
|
||||
fastModelValue = undefined;
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'hi' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not overwrite a manual title', async () => {
|
||||
chatRecordingService.recordCustomTitle('chose-this-myself', 'manual');
|
||||
vi.mocked(jsonl.writeLineSync).mockClear();
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'reply' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
expect(chatRecordingService.getCurrentCustomTitle()).toBe(
|
||||
'chose-this-myself',
|
||||
);
|
||||
expect(chatRecordingService.getCurrentTitleSource()).toBe('manual');
|
||||
});
|
||||
|
||||
it('retries on empty_result up to the cap, then stops', async () => {
|
||||
tryGenerateSessionTitleMock.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'empty_result',
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: `turn ${i}` }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
}
|
||||
|
||||
// Cap is 3.
|
||||
expect(tryGenerateSessionTitleMock).toHaveBeenCalledTimes(3);
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries across turns after a transient thrown error (up to cap)', async () => {
|
||||
// A transient error (network blip, 429, bad UTF-16 in one turn's history)
|
||||
// must NOT permanently disable auto-titling — the next turn should retry.
|
||||
// The attempt cap bounds total waste.
|
||||
tryGenerateSessionTitleMock
|
||||
.mockRejectedValueOnce(new Error('transient'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
title: 'Recovered title',
|
||||
modelUsed: 'qwen-turbo',
|
||||
});
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'turn 1' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'turn 2' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(tryGenerateSessionTitleMock).toHaveBeenCalledTimes(2);
|
||||
const titleRecord = findCustomTitleRecord();
|
||||
expect(titleRecord?.systemPayload).toEqual({
|
||||
customTitle: 'Recovered title',
|
||||
titleSource: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trigger when QWEN_DISABLE_AUTO_TITLE is set', async () => {
|
||||
const prev = process.env['QWEN_DISABLE_AUTO_TITLE'];
|
||||
process.env['QWEN_DISABLE_AUTO_TITLE'] = '1';
|
||||
try {
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'reply' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['QWEN_DISABLE_AUTO_TITLE'];
|
||||
else process.env['QWEN_DISABLE_AUTO_TITLE'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('still triggers when QWEN_DISABLE_AUTO_TITLE is falsy ("0")', async () => {
|
||||
mockOk('Fix login button');
|
||||
const prev = process.env['QWEN_DISABLE_AUTO_TITLE'];
|
||||
process.env['QWEN_DISABLE_AUTO_TITLE'] = '0';
|
||||
try {
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'reply' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['QWEN_DISABLE_AUTO_TITLE'];
|
||||
else process.env['QWEN_DISABLE_AUTO_TITLE'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trigger in non-interactive mode', async () => {
|
||||
vi.mocked(mockConfig.isInteractive).mockReturnValue(false);
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'reply' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prevents concurrent in-flight generations across rapid turns', async () => {
|
||||
// Generation never resolves (simulates slow LLM); successive turns
|
||||
// within the same process must NOT start additional generations.
|
||||
tryGenerateSessionTitleMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: `turn ${i}` }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
}
|
||||
|
||||
// Only the first turn should have launched a generation; subsequent
|
||||
// turns are blocked because autoTitleController is still set.
|
||||
expect(tryGenerateSessionTitleMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves titleSource across resume (auto stays auto)', async () => {
|
||||
const mockSessionService = {
|
||||
getSessionTitleInfo: vi.fn().mockReturnValue({
|
||||
title: 'Auto-generated title',
|
||||
source: 'auto',
|
||||
}),
|
||||
getSessionTitle: vi.fn().mockReturnValue('Auto-generated title'),
|
||||
};
|
||||
const resumedConfig = {
|
||||
...mockConfig,
|
||||
getResumedSessionData: vi.fn().mockReturnValue({
|
||||
lastCompletedUuid: 'parent-uuid',
|
||||
}),
|
||||
getSessionService: vi.fn().mockReturnValue(mockSessionService),
|
||||
} as unknown as Config;
|
||||
|
||||
const svc = new ChatRecordingService(resumedConfig);
|
||||
|
||||
expect(svc.getCurrentCustomTitle()).toBe('Auto-generated title');
|
||||
expect(svc.getCurrentTitleSource()).toBe('auto');
|
||||
|
||||
// finalize() was called by the constructor — the re-appended record
|
||||
// must carry titleSource: 'auto', not 'manual'.
|
||||
const finalizeRecord = vi
|
||||
.mocked(jsonl.writeLineSync)
|
||||
.mock.calls.map((c) => c[1] as ChatRecord)
|
||||
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
|
||||
expect(finalizeRecord?.systemPayload).toEqual({
|
||||
customTitle: 'Auto-generated title',
|
||||
titleSource: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves titleSource across resume (manual stays manual)', async () => {
|
||||
// Symmetric to the auto-stays-auto case: if a user deliberately ran
|
||||
// /rename on a session, resuming must NOT rewrite that to auto or to
|
||||
// anything else. The worst regression path here would silently
|
||||
// reclassify a user-chosen name as a model guess.
|
||||
const mockSessionService = {
|
||||
getSessionTitleInfo: vi.fn().mockReturnValue({
|
||||
title: 'User chose this',
|
||||
source: 'manual',
|
||||
}),
|
||||
getSessionTitle: vi.fn().mockReturnValue('User chose this'),
|
||||
};
|
||||
const resumedConfig = {
|
||||
...mockConfig,
|
||||
getResumedSessionData: vi.fn().mockReturnValue({
|
||||
lastCompletedUuid: 'parent-uuid',
|
||||
}),
|
||||
getSessionService: vi.fn().mockReturnValue(mockSessionService),
|
||||
} as unknown as Config;
|
||||
|
||||
const svc = new ChatRecordingService(resumedConfig);
|
||||
|
||||
expect(svc.getCurrentCustomTitle()).toBe('User chose this');
|
||||
expect(svc.getCurrentTitleSource()).toBe('manual');
|
||||
|
||||
const finalizeRecord = vi
|
||||
.mocked(jsonl.writeLineSync)
|
||||
.mock.calls.map((c) => c[1] as ChatRecord)
|
||||
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
|
||||
expect(finalizeRecord?.systemPayload).toEqual({
|
||||
customTitle: 'User chose this',
|
||||
titleSource: 'manual',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves undefined titleSource on legacy resume (no rewrite)', async () => {
|
||||
const mockSessionService = {
|
||||
// Legacy record: only title surfaces, no source field.
|
||||
getSessionTitleInfo: vi.fn().mockReturnValue({
|
||||
title: 'Legacy title',
|
||||
}),
|
||||
getSessionTitle: vi.fn().mockReturnValue('Legacy title'),
|
||||
};
|
||||
const resumedConfig = {
|
||||
...mockConfig,
|
||||
getResumedSessionData: vi.fn().mockReturnValue({
|
||||
lastCompletedUuid: 'parent-uuid',
|
||||
}),
|
||||
getSessionService: vi.fn().mockReturnValue(mockSessionService),
|
||||
} as unknown as Config;
|
||||
|
||||
const svc = new ChatRecordingService(resumedConfig);
|
||||
|
||||
expect(svc.getCurrentCustomTitle()).toBe('Legacy title');
|
||||
// Must stay undefined so the JSONL isn't upgraded to a misleading
|
||||
// `titleSource: 'manual'` we can't actually verify.
|
||||
expect(svc.getCurrentTitleSource()).toBeUndefined();
|
||||
|
||||
const finalizeRecord = vi
|
||||
.mocked(jsonl.writeLineSync)
|
||||
.mock.calls.map((c) => c[1] as ChatRecord)
|
||||
.find((r) => r.type === 'system' && r.subtype === 'custom_title');
|
||||
// Payload must NOT contain a titleSource field when source is unknown.
|
||||
expect(finalizeRecord?.systemPayload).toEqual({
|
||||
customTitle: 'Legacy title',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite a manual title written by another process', async () => {
|
||||
// Cross-process race: this CRS instance doesn't know about a /rename
|
||||
// issued from another CLI tab, but the persisted JSONL does. Before
|
||||
// writing an auto title we must re-read and bail if the file already
|
||||
// has source='manual'.
|
||||
mockOk('Auto guess');
|
||||
const otherProcessManual = vi.fn().mockReturnValue({
|
||||
title: 'User chose this',
|
||||
source: 'manual',
|
||||
});
|
||||
vi.mocked(mockConfig.getSessionService).mockReturnValue({
|
||||
getSessionTitleInfo: otherProcessManual,
|
||||
getSessionTitle: vi.fn(),
|
||||
} as never);
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'reply' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
|
||||
expect(otherProcessManual).toHaveBeenCalled();
|
||||
// No auto record was appended.
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
// In-memory state synced to the on-disk manual title so later turns
|
||||
// also skip the trigger.
|
||||
expect(chatRecordingService.getCurrentCustomTitle()).toBe(
|
||||
'User chose this',
|
||||
);
|
||||
expect(chatRecordingService.getCurrentTitleSource()).toBe('manual');
|
||||
});
|
||||
|
||||
it('aborts the in-flight generation on finalize and suppresses the title write', async () => {
|
||||
// Model rejects when the signal fires — mirrors what a real provider's
|
||||
// fetch layer does when the AbortController aborts. Previously this
|
||||
// test only checked that `signal.aborted` flipped; but what we actually
|
||||
// care about is that NO custom_title record gets written after abort.
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
tryGenerateSessionTitleMock.mockImplementation(
|
||||
(_config: unknown, signal: AbortSignal) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
capturedSignal = signal;
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new Error('aborted'));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'turn' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(capturedSignal).toBeDefined();
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
// No title yet — generation is still pending.
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
|
||||
chatRecordingService.finalize();
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
|
||||
await flushMicrotasks();
|
||||
// The aborted generation must NOT result in a custom_title record —
|
||||
// even though the mock technically "completed" (via rejection).
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
expect(chatRecordingService.getCurrentCustomTitle()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects a late /rename that lands while the LLM call is in flight', async () => {
|
||||
// Simulate slow LLM: resolves after a manual rename lands.
|
||||
let resolveLlm: (v: unknown) => void = () => {};
|
||||
tryGenerateSessionTitleMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveLlm = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
chatRecordingService.recordAssistantTurn({
|
||||
model: 'qwen-plus',
|
||||
message: [{ text: 'turn' }],
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
// User renames while the title LLM call is still pending.
|
||||
chatRecordingService.recordCustomTitle('user-chosen', 'manual');
|
||||
vi.mocked(jsonl.writeLineSync).mockClear();
|
||||
|
||||
// Now the LLM call returns a title.
|
||||
resolveLlm({ ok: true, title: 'Auto Title', modelUsed: 'qwen-turbo' });
|
||||
await flushMicrotasks();
|
||||
|
||||
// No auto-title record should have been written.
|
||||
expect(findCustomTitleRecord()).toBeUndefined();
|
||||
expect(chatRecordingService.getCurrentCustomTitle()).toBe('user-chosen');
|
||||
expect(chatRecordingService.getCurrentTitleSource()).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
|
@ -50,6 +50,8 @@ describe('ChatRecordingService - recordCustomTitle', () => {
|
|||
.mockReturnValue('/test/project/root/.qwen/projects/test-project'),
|
||||
},
|
||||
getModel: vi.fn().mockReturnValue('qwen-plus'),
|
||||
getFastModel: vi.fn().mockReturnValue(undefined),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn().mockReturnValue({
|
||||
|
|
@ -94,6 +96,7 @@ describe('ChatRecordingService - recordCustomTitle', () => {
|
|||
expect(writtenRecord.subtype).toBe('custom_title');
|
||||
expect(writtenRecord.systemPayload).toEqual({
|
||||
customTitle: 'my-feature',
|
||||
titleSource: 'manual',
|
||||
});
|
||||
expect(writtenRecord.sessionId).toBe('test-session-id');
|
||||
});
|
||||
|
|
@ -137,7 +140,10 @@ describe('ChatRecordingService - recordCustomTitle', () => {
|
|||
.calls[0][1] as ChatRecord;
|
||||
expect(record.type).toBe('system');
|
||||
expect(record.subtype).toBe('custom_title');
|
||||
expect(record.systemPayload).toEqual({ customTitle: 'my-feature' });
|
||||
expect(record.systemPayload).toEqual({
|
||||
customTitle: 'my-feature',
|
||||
titleSource: 'manual',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not write anything when no custom title was set', () => {
|
||||
|
|
@ -156,7 +162,10 @@ describe('ChatRecordingService - recordCustomTitle', () => {
|
|||
expect(jsonl.writeLineSync).toHaveBeenCalledOnce();
|
||||
const record = vi.mocked(jsonl.writeLineSync).mock
|
||||
.calls[0][1] as ChatRecord;
|
||||
expect(record.systemPayload).toEqual({ customTitle: 'second-name' });
|
||||
expect(record.systemPayload).toEqual({
|
||||
customTitle: 'second-name',
|
||||
titleSource: 'manual',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ describe('ChatRecordingService', () => {
|
|||
.mockReturnValue('/test/project/root/.gemini/projects/test-project'),
|
||||
},
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getFastModel: vi.fn().mockReturnValue(undefined),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn().mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ import {
|
|||
import * as jsonl from '../utils/jsonl-utils.js';
|
||||
import { getGitBranch } from '../utils/gitUtils.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
const debugLogger = createDebugLogger('CHAT_RECORDING');
|
||||
import { tryGenerateSessionTitle } from './sessionTitle.js';
|
||||
import type {
|
||||
ChatCompressionInfo,
|
||||
ToolCallResponseInfo,
|
||||
|
|
@ -28,6 +27,38 @@ import type { Status } from '../core/coreToolScheduler.js';
|
|||
import type { AgentResultDisplay } from '../tools/tools.js';
|
||||
import type { UiEvent } from '../telemetry/uiTelemetry.js';
|
||||
|
||||
const debugLogger = createDebugLogger('CHAT_RECORDING');
|
||||
|
||||
/**
|
||||
* Maximum number of auto-title generation attempts per session. See
|
||||
* {@link ChatRecordingService.autoTitleAttempts} for the rationale behind
|
||||
* retrying across turns.
|
||||
*/
|
||||
const AUTO_TITLE_ATTEMPT_CAP = 3;
|
||||
|
||||
/**
|
||||
* Users who don't want the fast model silently generating titles can opt
|
||||
* out at runtime: `QWEN_DISABLE_AUTO_TITLE=1` (or any truthy-ish value)
|
||||
* makes {@link ChatRecordingService.maybeTriggerAutoTitle} a no-op without
|
||||
* touching the rest of the feature (so `/rename --auto` still works on
|
||||
* explicit user request). Read per-call rather than cached so tests can
|
||||
* flip the var between cases without reloading the module; the cost of
|
||||
* one env lookup per assistant turn is irrelevant next to an LLM call.
|
||||
*/
|
||||
function autoTitleDisabledByEnv(): boolean {
|
||||
const v = process.env['QWEN_DISABLE_AUTO_TITLE'];
|
||||
if (!v) return false;
|
||||
// Accept "0", "false", "no", "off" (case-insensitive) as "not disabled".
|
||||
const lowered = v.trim().toLowerCase();
|
||||
return (
|
||||
lowered !== '' &&
|
||||
lowered !== '0' &&
|
||||
lowered !== 'false' &&
|
||||
lowered !== 'no' &&
|
||||
lowered !== 'off'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single record stored in the JSONL file.
|
||||
* Forms a tree structure via uuid/parentUuid for future checkpointing support.
|
||||
|
|
@ -151,11 +182,27 @@ export interface AtCommandRecordPayload {
|
|||
}
|
||||
|
||||
/**
|
||||
* Stored payload for custom title set via /rename.
|
||||
* Source of a custom session title.
|
||||
* - `manual`: set by the user via `/rename` (or pre-2026 records without
|
||||
* a source field — treated as manual for safety so auto can't overwrite
|
||||
* a title a user deliberately chose).
|
||||
* - `auto`: generated by the session-title service from conversation text;
|
||||
* safe to re-generate or be replaced by a manual rename.
|
||||
*/
|
||||
export type TitleSource = 'manual' | 'auto';
|
||||
|
||||
/**
|
||||
* Stored payload for custom title set via /rename or auto-generation.
|
||||
*/
|
||||
export interface CustomTitleRecordPayload {
|
||||
/** The custom title for the session */
|
||||
customTitle: string;
|
||||
/**
|
||||
* How this title was produced. Absent on legacy records — readers should
|
||||
* treat `undefined` as `'manual'` so existing user-set titles are never
|
||||
* replaced by auto-generation after an upgrade.
|
||||
*/
|
||||
titleSource?: TitleSource;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -195,23 +242,55 @@ export class ChatRecordingService {
|
|||
private readonly config: Config;
|
||||
/** In-memory cache of the current session's custom title (for re-append on exit) */
|
||||
private currentCustomTitle: string | undefined;
|
||||
/**
|
||||
* Source of {@link currentCustomTitle}. `undefined` on legacy records that
|
||||
* pre-date the `titleSource` field — that's treated as manual everywhere
|
||||
* (safe default) without rewriting the persisted record.
|
||||
*/
|
||||
private currentTitleSource: TitleSource | undefined;
|
||||
/**
|
||||
* How many auto-title attempts have been made this process.
|
||||
*
|
||||
* We don't commit to "one attempt per session" because the first assistant
|
||||
* turn may be a pure tool-call with no user-visible text (e.g., the model
|
||||
* opens with a search) — the title service returns null, and we'd waste
|
||||
* the whole session's chance on a turn that never had a shot. Instead we
|
||||
* retry for a handful of turns until either the title lands or we hit the
|
||||
* cap, which protects against a persistently failing fast-model looping
|
||||
* on every turn. {@link AUTO_TITLE_ATTEMPT_CAP} sets the ceiling.
|
||||
*/
|
||||
private autoTitleAttempts = 0;
|
||||
/**
|
||||
* AbortController for the in-flight auto-title LLM call, or `undefined`
|
||||
* when no generation is pending. Doubles as the in-flight guard — a
|
||||
* defined controller means "one is running; don't launch another".
|
||||
* Stored on the instance so {@link finalize} (called on session switch
|
||||
* and shutdown) can cancel a pending call cleanly rather than letting
|
||||
* it burn tokens after the session has already moved on.
|
||||
*/
|
||||
private autoTitleController: AbortController | undefined;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.lastRecordUuid =
|
||||
config.getResumedSessionData()?.lastCompletedUuid ?? null;
|
||||
|
||||
// On resume, load the cached custom title from the session file and
|
||||
// immediately re-append it to EOF. This keeps the title within the
|
||||
// 64KB tail window even as new messages push it deeper into the file.
|
||||
// Without this, a crash mid-session could lose the title if the exit
|
||||
// re-append never runs.
|
||||
// On resume, load the cached custom title AND its source from the
|
||||
// session file. Preserving the persisted source is load-bearing: the
|
||||
// SessionPicker dim-styling depends on it, and hardcoding `'manual'`
|
||||
// would silently downgrade auto-titled sessions every time they get
|
||||
// resumed. Legacy records (no `titleSource` field) stay `undefined` —
|
||||
// treated as manual for safety without rewriting the JSONL.
|
||||
//
|
||||
// We then re-append a custom_title record to EOF so the title stays
|
||||
// within the tail window that readers scan (guarding against a crash
|
||||
// before the next finalize).
|
||||
if (config.getResumedSessionData()) {
|
||||
try {
|
||||
const sessionService = config.getSessionService();
|
||||
this.currentCustomTitle = sessionService.getSessionTitle(
|
||||
config.getSessionId(),
|
||||
);
|
||||
const info = sessionService.getSessionTitleInfo(config.getSessionId());
|
||||
this.currentCustomTitle = info.title;
|
||||
this.currentTitleSource = info.source;
|
||||
this.finalize();
|
||||
} catch {
|
||||
// Best-effort — don't block construction
|
||||
|
|
@ -219,6 +298,23 @@ export class ChatRecordingService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current custom title, if any. Read-only accessor for
|
||||
* callers (e.g. auto-title trigger) that need to know whether a title is
|
||||
* already set before attempting generation.
|
||||
*/
|
||||
getCurrentCustomTitle(): string | undefined {
|
||||
return this.currentCustomTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the source of the current custom title, or `undefined` when no
|
||||
* title is set.
|
||||
*/
|
||||
getCurrentTitleSource(): TitleSource | undefined {
|
||||
return this.currentTitleSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session ID.
|
||||
* @returns The session ID.
|
||||
|
|
@ -403,11 +499,97 @@ export class ChatRecordingService {
|
|||
}
|
||||
|
||||
this.appendRecord(record);
|
||||
this.maybeTriggerAutoTitle();
|
||||
} catch (error) {
|
||||
debugLogger.error('Error saving assistant turn:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: after an assistant turn is recorded, attempt to generate
|
||||
* a short session title from the conversation so far. Runs at most once per
|
||||
* process lifetime per session and only when:
|
||||
*
|
||||
* - No title is already set (auto must never overwrite a manual rename,
|
||||
* and we don't need to regenerate an existing auto title mid-session).
|
||||
* - A fast model is configured — the service itself also guards this,
|
||||
* but checking here avoids paying for the import/history load when
|
||||
* there's no point.
|
||||
*
|
||||
* Errors are swallowed. The title is best-effort and must never surface
|
||||
* as a user-visible error or interrupt recording.
|
||||
*/
|
||||
private maybeTriggerAutoTitle(): void {
|
||||
if (this.currentCustomTitle) return;
|
||||
if (this.autoTitleController) return;
|
||||
if (this.autoTitleAttempts >= AUTO_TITLE_ATTEMPT_CAP) return;
|
||||
// Opt-out env var — lets users silence auto-titling without having to
|
||||
// unset their fast model (which would break `/rename --auto`, recap,
|
||||
// compression, and other fast-model features).
|
||||
if (autoTitleDisabledByEnv()) return;
|
||||
// Headless/one-shot CLI flows (`qwen -p "…"`, cron, CI scripts) run a
|
||||
// single prompt and throw the session away. Spending fast-model tokens
|
||||
// on a title no one will ever resume is pure waste; skip entirely.
|
||||
// Checked before `getFastModel()` because it's strictly cheaper (a bool
|
||||
// field read vs. a method that looks up available models for the auth
|
||||
// type).
|
||||
if (!this.config.isInteractive()) return;
|
||||
if (!this.config.getFastModel()) return;
|
||||
|
||||
this.autoTitleAttempts++;
|
||||
const controller = new AbortController();
|
||||
this.autoTitleController = controller;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
this.config,
|
||||
controller.signal,
|
||||
);
|
||||
if (!outcome.ok) return;
|
||||
if (controller.signal.aborted) return;
|
||||
// Re-check in case a /rename landed while the LLM call was in flight —
|
||||
// manual wins. In-process is the common path.
|
||||
if (this.currentTitleSource === 'manual') return;
|
||||
// Cross-process guard: another CLI tab writing to the same JSONL
|
||||
// could have renamed (manually) since we started. Re-read the file's
|
||||
// latest title record before we append so we don't clobber it.
|
||||
// Cost is one 64KB tail read; happens once per successful generation.
|
||||
try {
|
||||
const sessionService = this.config.getSessionService();
|
||||
const onDisk = sessionService.getSessionTitleInfo(
|
||||
this.config.getSessionId(),
|
||||
);
|
||||
if (onDisk.source === 'manual') {
|
||||
// Sync in-memory state with what landed on disk so subsequent
|
||||
// turns don't retry against a stale cache.
|
||||
this.currentCustomTitle = onDisk.title;
|
||||
this.currentTitleSource = 'manual';
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — if the re-read fails for any reason, fall through
|
||||
// to the in-process check (which already passed) and proceed.
|
||||
}
|
||||
this.recordCustomTitle(outcome.title, 'auto');
|
||||
} catch (err) {
|
||||
// Don't permanently disable: transient failures (network blips, rate
|
||||
// limits, bad UTF-16 in one turn's history) should still allow a
|
||||
// later turn to retry. The attempt cap bounds total waste.
|
||||
debugLogger.warn(
|
||||
`Auto-title generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
// Clear only if we're still the active controller — `finalize()`
|
||||
// may have swapped to a new one during a subsequent session, and
|
||||
// we shouldn't overwrite that.
|
||||
if (this.autoTitleController === controller) {
|
||||
this.autoTitleController = undefined;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Records tool results (function responses) sent back to the model.
|
||||
* Writes immediately to disk.
|
||||
|
|
@ -511,23 +693,30 @@ export class ChatRecordingService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Records a custom title for the session (set via /rename).
|
||||
* Records a custom title for the session.
|
||||
* Appended as a system record so it persists with the session data.
|
||||
* Also caches the title in memory for re-append on shutdown.
|
||||
*
|
||||
* @param customTitle The title text.
|
||||
* @param titleSource Where the title came from — defaults to `'manual'`
|
||||
* so existing `/rename` call sites keep their behavior unchanged.
|
||||
* @returns true if the record was written successfully, false on I/O error.
|
||||
*/
|
||||
recordCustomTitle(customTitle: string): boolean {
|
||||
recordCustomTitle(
|
||||
customTitle: string,
|
||||
titleSource: TitleSource = 'manual',
|
||||
): boolean {
|
||||
try {
|
||||
const record: ChatRecord = {
|
||||
...this.createBaseRecord('system'),
|
||||
type: 'system',
|
||||
subtype: 'custom_title',
|
||||
systemPayload: { customTitle },
|
||||
systemPayload: { customTitle, titleSource },
|
||||
};
|
||||
|
||||
this.appendRecord(record);
|
||||
this.currentCustomTitle = customTitle;
|
||||
this.currentTitleSource = titleSource;
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugLogger.error('Error saving custom title record:', error);
|
||||
|
|
@ -547,6 +736,17 @@ export class ChatRecordingService {
|
|||
* Best-effort: errors are logged but never thrown.
|
||||
*/
|
||||
finalize(): void {
|
||||
// Cancel any pending auto-title LLM call — the session is transitioning
|
||||
// (switch / shutdown) and the result is no longer useful. Without this,
|
||||
// a slow fast-model call could keep a socket open past the logical end
|
||||
// of the session.
|
||||
if (this.autoTitleController) {
|
||||
try {
|
||||
this.autoTitleController.abort();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
if (!this.currentCustomTitle) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -555,7 +755,12 @@ export class ChatRecordingService {
|
|||
...this.createBaseRecord('system'),
|
||||
type: 'system',
|
||||
subtype: 'custom_title',
|
||||
systemPayload: { customTitle: this.currentCustomTitle },
|
||||
systemPayload: {
|
||||
customTitle: this.currentCustomTitle,
|
||||
...(this.currentTitleSource
|
||||
? { titleSource: this.currentTitleSource }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
this.appendRecord(record);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ describe('SessionService - rename and custom title', () => {
|
|||
expect(writtenRecord.subtype).toBe('custom_title');
|
||||
expect(writtenRecord.systemPayload).toEqual({
|
||||
customTitle: 'my-feature',
|
||||
titleSource: 'manual',
|
||||
});
|
||||
expect(writtenRecord.sessionId).toBe(sessionIdA);
|
||||
});
|
||||
|
|
@ -501,6 +502,85 @@ describe('SessionService - rename and custom title', () => {
|
|||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].customTitle).toBeUndefined();
|
||||
expect(result.items[0].titleSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should surface titleSource on session list items', async () => {
|
||||
const now = Date.now();
|
||||
const titleContent =
|
||||
JSON.stringify({
|
||||
type: 'system',
|
||||
subtype: 'custom_title',
|
||||
systemPayload: {
|
||||
customTitle: 'Fix login bug',
|
||||
titleSource: 'auto',
|
||||
},
|
||||
}) + '\n';
|
||||
|
||||
readdirSyncSpy.mockReturnValue([
|
||||
`${sessionIdA}.jsonl`,
|
||||
] as unknown as Array<fs.Dirent<Buffer>>);
|
||||
|
||||
statSyncSpy.mockReturnValue({
|
||||
mtimeMs: now,
|
||||
size: titleContent.length,
|
||||
isFile: () => true,
|
||||
} as unknown as fs.Stats);
|
||||
|
||||
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
|
||||
|
||||
readSyncSpy.mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(_fd: number, buffer: any) => {
|
||||
const data = Buffer.from(titleContent);
|
||||
data.copy(buffer);
|
||||
return data.length;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await sessionService.listSessions();
|
||||
|
||||
expect(result.items[0].customTitle).toBe('Fix login bug');
|
||||
expect(result.items[0].titleSource).toBe('auto');
|
||||
});
|
||||
|
||||
it('leaves titleSource undefined for legacy records without the field', async () => {
|
||||
// Back-compat: old sessions written before titleSource existed should
|
||||
// be treated as manual (via `undefined` — consumers check `=== 'auto'`),
|
||||
// so auto-generation never dims a title the user chose pre-upgrade.
|
||||
const now = Date.now();
|
||||
const titleContent =
|
||||
JSON.stringify({
|
||||
type: 'system',
|
||||
subtype: 'custom_title',
|
||||
systemPayload: { customTitle: 'legacy-title' },
|
||||
}) + '\n';
|
||||
|
||||
readdirSyncSpy.mockReturnValue([
|
||||
`${sessionIdA}.jsonl`,
|
||||
] as unknown as Array<fs.Dirent<Buffer>>);
|
||||
|
||||
statSyncSpy.mockReturnValue({
|
||||
mtimeMs: now,
|
||||
size: titleContent.length,
|
||||
isFile: () => true,
|
||||
} as unknown as fs.Stats);
|
||||
|
||||
vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]);
|
||||
|
||||
readSyncSpy.mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(_fd: number, buffer: any) => {
|
||||
const data = Buffer.from(titleContent);
|
||||
data.copy(buffer);
|
||||
return data.length;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await sessionService.listSessions();
|
||||
|
||||
expect(result.items[0].customTitle).toBe('legacy-title');
|
||||
expect(result.items[0].titleSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,11 +15,15 @@ import * as jsonl from '../utils/jsonl-utils.js';
|
|||
import type {
|
||||
ChatCompressionRecordPayload,
|
||||
ChatRecord,
|
||||
TitleSource,
|
||||
UiTelemetryRecordPayload,
|
||||
} from './chatRecordingService.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { readLastJsonStringFieldSync } from '../utils/sessionStorageUtils.js';
|
||||
import {
|
||||
readLastJsonStringFieldSync,
|
||||
readLastJsonStringFieldsSync,
|
||||
} from '../utils/sessionStorageUtils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SESSION');
|
||||
|
||||
|
|
@ -44,8 +48,16 @@ export interface SessionListItem {
|
|||
filePath: string;
|
||||
/** Number of messages in the session (unique message UUIDs) */
|
||||
messageCount: number;
|
||||
/** Custom title set via /rename, if any */
|
||||
/** Custom title set via /rename or auto-generated by the title service, if any */
|
||||
customTitle?: string;
|
||||
/**
|
||||
* Source of {@link customTitle}. `undefined` on legacy records without a
|
||||
* source field — consumers should treat `undefined` and `'manual'` the
|
||||
* same way for display (full-contrast). UI affordances like dimming are
|
||||
* reserved for `'auto'` so users can tell a model guess from a name they
|
||||
* chose.
|
||||
*/
|
||||
titleSource?: TitleSource;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -164,7 +176,62 @@ export class SessionService {
|
|||
* field.
|
||||
*/
|
||||
private readSessionTitleFromFile(filePath: string): string | undefined {
|
||||
return readLastJsonStringFieldSync(filePath, 'customTitle', 'custom_title');
|
||||
// Match only on actual custom_title system records. `'custom_title'` as
|
||||
// a loose substring can land on a user message that happens to contain
|
||||
// the literal "custom_title" (code review of this very file, etc.);
|
||||
// requiring the full `"subtype":"custom_title"` pattern guarantees the
|
||||
// match is on a system record written by {@link writeLineSync}, which
|
||||
// JSON.stringifies records in a predictable compact form.
|
||||
return readLastJsonStringFieldSync(
|
||||
filePath,
|
||||
'customTitle',
|
||||
'"subtype":"custom_title"',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads both the custom title and its source from a session file in a
|
||||
* single pass — the helper extracts both fields from the same matching
|
||||
* `custom_title` line, so the pair is always consistent (never one field
|
||||
* from an old record and another from a new one).
|
||||
*
|
||||
* `titleSource` is absent on legacy records written before the field was
|
||||
* introduced — callers treat `undefined` as equivalent to `'manual'` so a
|
||||
* user's pre-upgrade rename is never displayed as if it were auto-generated.
|
||||
*/
|
||||
private readSessionTitleInfoFromFile(filePath: string): {
|
||||
title?: string;
|
||||
source?: TitleSource;
|
||||
} {
|
||||
const hit = readLastJsonStringFieldsSync(
|
||||
filePath,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'"subtype":"custom_title"',
|
||||
);
|
||||
const title = hit['customTitle'];
|
||||
if (!title) return {};
|
||||
const rawSource = hit['titleSource'];
|
||||
const source =
|
||||
rawSource === 'auto' || rawSource === 'manual' ? rawSource : undefined;
|
||||
return { title, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* Public accessor: returns both the current custom title and its source
|
||||
* for a given session. Used by `ChatRecordingService` on resume to
|
||||
* preserve the persisted `titleSource` rather than defaulting to manual.
|
||||
*/
|
||||
getSessionTitleInfo(sessionId: string): {
|
||||
title?: string;
|
||||
source?: TitleSource;
|
||||
} {
|
||||
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
|
||||
return {};
|
||||
}
|
||||
const chatsDir = this.getChatsDir();
|
||||
const filePath = path.join(chatsDir, `${sessionId}.jsonl`);
|
||||
return this.readSessionTitleInfoFromFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -365,6 +432,7 @@ export class SessionService {
|
|||
|
||||
const prompt = this.extractFirstPromptFromRecords(records);
|
||||
|
||||
const titleInfo = this.readSessionTitleInfoFromFile(filePath);
|
||||
items.push({
|
||||
sessionId: firstRecord.sessionId,
|
||||
cwd: firstRecord.cwd,
|
||||
|
|
@ -374,7 +442,8 @@ export class SessionService {
|
|||
gitBranch: firstRecord.gitBranch,
|
||||
filePath,
|
||||
messageCount,
|
||||
customTitle: this.readSessionTitleFromFile(filePath),
|
||||
customTitle: titleInfo.title,
|
||||
titleSource: titleInfo.source,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -589,9 +658,16 @@ export class SessionService {
|
|||
*
|
||||
* @param sessionId The session ID to rename
|
||||
* @param title The new custom title
|
||||
* @param titleSource Where the title came from. Defaults to `'manual'` so
|
||||
* existing callers are unchanged — pass `'auto'` only for titles produced
|
||||
* by the auto-title generator.
|
||||
* @returns true if renamed successfully, false if session not found
|
||||
*/
|
||||
async renameSession(sessionId: string, title: string): Promise<boolean> {
|
||||
async renameSession(
|
||||
sessionId: string,
|
||||
title: string,
|
||||
titleSource: TitleSource = 'manual',
|
||||
): Promise<boolean> {
|
||||
if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -616,7 +692,11 @@ export class SessionService {
|
|||
// chain and cause the session to appear empty on next load.
|
||||
const lastUuid = this.readLastRecordUuid(filePath);
|
||||
|
||||
// Append a custom_title system record
|
||||
// Append a custom_title system record. `renameSession` is the
|
||||
// fallback path when no live recording service is attached (e.g., from
|
||||
// the WebUI or VSCode extension). Callers pass `titleSource='auto'`
|
||||
// only when the title came from the auto-generator; defaults to
|
||||
// 'manual' for explicit user renames.
|
||||
const titleRecord: ChatRecord = {
|
||||
uuid: randomUUID(),
|
||||
parentUuid: lastUuid,
|
||||
|
|
@ -626,7 +706,7 @@ export class SessionService {
|
|||
subtype: 'custom_title',
|
||||
cwd: records[0].cwd,
|
||||
version: records[0].version,
|
||||
systemPayload: { customTitle: title },
|
||||
systemPayload: { customTitle: title, titleSource },
|
||||
};
|
||||
jsonl.writeLineSync(filePath, titleRecord);
|
||||
return true;
|
||||
|
|
@ -705,8 +785,8 @@ export class SessionService {
|
|||
// Cheap check first: tail-read the title and skip non-matches before
|
||||
// doing the full hydration work (first-record read, project filter,
|
||||
// message count, prompt extraction).
|
||||
const customTitle = this.readSessionTitleFromFile(filePath);
|
||||
if (customTitle?.toLowerCase().trim() !== normalizedTitle) continue;
|
||||
const titleInfo = this.readSessionTitleInfoFromFile(filePath);
|
||||
if (titleInfo.title?.toLowerCase().trim() !== normalizedTitle) continue;
|
||||
|
||||
const records = await jsonl.readLines<ChatRecord>(
|
||||
filePath,
|
||||
|
|
@ -730,7 +810,8 @@ export class SessionService {
|
|||
gitBranch: firstRecord.gitBranch,
|
||||
filePath,
|
||||
messageCount,
|
||||
customTitle,
|
||||
customTitle: titleInfo.title,
|
||||
titleSource: titleInfo.source,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
303
packages/core/src/services/sessionTitle.test.ts
Normal file
303
packages/core/src/services/sessionTitle.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { sanitizeTitle, tryGenerateSessionTitle } from './sessionTitle.js';
|
||||
|
||||
interface MockOptions {
|
||||
fastModel?: string | undefined;
|
||||
history?: Content[];
|
||||
generateJsonResult?:
|
||||
| Record<string, unknown>
|
||||
| ((...args: unknown[]) => Promise<Record<string, unknown>>);
|
||||
}
|
||||
|
||||
function makeConfig(opts: MockOptions): {
|
||||
config: Config;
|
||||
generateJson: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const generateJson = vi.fn(async (...args: unknown[]) => {
|
||||
const r = opts.generateJsonResult;
|
||||
if (!r) throw new Error('no generateJsonResult configured');
|
||||
return typeof r === 'function' ? r(...args) : r;
|
||||
});
|
||||
|
||||
const config = {
|
||||
getFastModel: vi.fn(() => opts.fastModel ?? undefined),
|
||||
getModel: vi.fn(() => 'qwen-plus'),
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
getChat: () => ({
|
||||
getHistory: () => opts.history ?? [],
|
||||
}),
|
||||
})),
|
||||
getBaseLlmClient: vi.fn(() => ({ generateJson })),
|
||||
} as unknown as Config;
|
||||
|
||||
return { config, generateJson };
|
||||
}
|
||||
|
||||
const DIALOG_HISTORY: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'my login button is broken on mobile' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: "Let's look at the button handler and the viewport CSS." }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('tryGenerateSessionTitle', () => {
|
||||
it('returns {ok:false, reason:"no_fast_model"} when fast model is absent', async () => {
|
||||
const { config } = makeConfig({
|
||||
fastModel: undefined,
|
||||
history: DIALOG_HISTORY,
|
||||
});
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(outcome).toEqual({ ok: false, reason: 'no_fast_model' });
|
||||
});
|
||||
|
||||
it('returns {ok:false, reason:"empty_history"} for a fresh session', async () => {
|
||||
const { config } = makeConfig({
|
||||
fastModel: 'qwen-turbo',
|
||||
history: [],
|
||||
});
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(outcome).toEqual({ ok: false, reason: 'empty_history' });
|
||||
});
|
||||
|
||||
it('returns {ok:false, reason:"model_error"} when the LLM throws', async () => {
|
||||
const { config } = makeConfig({
|
||||
fastModel: 'qwen-turbo',
|
||||
history: DIALOG_HISTORY,
|
||||
generateJsonResult: () => Promise.reject(new Error('API down')),
|
||||
});
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(outcome).toEqual({ ok: false, reason: 'model_error' });
|
||||
});
|
||||
|
||||
it('returns {ok:false, reason:"aborted"} when the user cancels', async () => {
|
||||
const controller = new AbortController();
|
||||
const { config } = makeConfig({
|
||||
fastModel: 'qwen-turbo',
|
||||
history: DIALOG_HISTORY,
|
||||
generateJsonResult: async () => {
|
||||
controller.abort();
|
||||
throw new Error('aborted');
|
||||
},
|
||||
});
|
||||
const outcome = await tryGenerateSessionTitle(config, controller.signal);
|
||||
expect(outcome).toEqual({ ok: false, reason: 'aborted' });
|
||||
});
|
||||
|
||||
it('returns {ok:false, reason:"empty_result"} when the model returns junk', async () => {
|
||||
const { config } = makeConfig({
|
||||
fastModel: 'qwen-turbo',
|
||||
history: DIALOG_HISTORY,
|
||||
generateJsonResult: { title: ' ... ' },
|
||||
});
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(outcome).toEqual({ ok: false, reason: 'empty_result' });
|
||||
});
|
||||
|
||||
it('returns {ok:true, title, modelUsed} on success', async () => {
|
||||
const { config, generateJson } = makeConfig({
|
||||
fastModel: 'qwen-turbo',
|
||||
history: DIALOG_HISTORY,
|
||||
generateJsonResult: { title: 'Fix login button on mobile' },
|
||||
});
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
title: 'Fix login button on mobile',
|
||||
modelUsed: 'qwen-turbo',
|
||||
});
|
||||
// Schema call must use the fast model (not the main model) and the
|
||||
// canonical title schema with required:['title'] and maxAttempts:1.
|
||||
expect(generateJson).toHaveBeenCalledOnce();
|
||||
const callOpts = generateJson.mock.calls[0][0] as {
|
||||
model: string;
|
||||
schema: {
|
||||
type: string;
|
||||
required: string[];
|
||||
properties: { title: { type: string } };
|
||||
};
|
||||
maxAttempts: number;
|
||||
};
|
||||
expect(callOpts.model).toBe('qwen-turbo');
|
||||
expect(callOpts.schema.type).toBe('object');
|
||||
expect(callOpts.schema.required).toEqual(['title']);
|
||||
expect(callOpts.schema.properties.title.type).toBe('string');
|
||||
expect(callOpts.maxAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('sanitizes residual markdown and trailing punctuation from the model result', async () => {
|
||||
const { config } = makeConfig({
|
||||
fastModel: 'qwen-turbo',
|
||||
history: DIALOG_HISTORY,
|
||||
generateJsonResult: { title: '**Fix login button.**' },
|
||||
});
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(outcome).toMatchObject({ ok: true, title: 'Fix login button' });
|
||||
});
|
||||
|
||||
it('filters tool-call and tool-result turns from the prompt', async () => {
|
||||
// Users' tool invocations can carry 10K-token payloads (file dumps, grep
|
||||
// output). Those must never reach the title LLM — both for cost and
|
||||
// because they dilute the "what is this session about" signal.
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'scan the auth module' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Scanning…' },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ functionCall: { name: 'grep', args: { q: 'auth' } } } as any,
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'grep',
|
||||
response: { output: 'TEN_THOUSAND_TOKENS_OF_FILE_DUMP' },
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'The middleware stores tokens unsafely.' }],
|
||||
},
|
||||
];
|
||||
|
||||
let capturedContents: Content[] | null = null;
|
||||
const generateJson = vi.fn(async (opts: { contents: Content[] }) => {
|
||||
capturedContents = opts.contents;
|
||||
return { title: 'Audit auth middleware' };
|
||||
});
|
||||
const config = {
|
||||
getFastModel: vi.fn(() => 'qwen-turbo'),
|
||||
getModel: vi.fn(() => 'qwen-plus'),
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
getChat: () => ({ getHistory: () => history }),
|
||||
})),
|
||||
getBaseLlmClient: vi.fn(() => ({ generateJson })),
|
||||
} as unknown as Config;
|
||||
|
||||
const outcome = await tryGenerateSessionTitle(
|
||||
config,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(outcome).toMatchObject({ ok: true, title: 'Audit auth middleware' });
|
||||
|
||||
// The tool-response payload must NOT have leaked into the prompt.
|
||||
expect(capturedContents).not.toBeNull();
|
||||
const serialized = JSON.stringify(capturedContents);
|
||||
expect(serialized).not.toContain('TEN_THOUSAND_TOKENS_OF_FILE_DUMP');
|
||||
expect(serialized).toContain('scan the auth module');
|
||||
expect(serialized).toContain('middleware stores tokens unsafely');
|
||||
});
|
||||
|
||||
it('tail-slices conversations longer than 1000 characters', async () => {
|
||||
// A session that pivots mid-conversation — the final topic is what the
|
||||
// title should reflect. Feeding the head risks titling the session by
|
||||
// what the user opened with rather than what they ended up doing.
|
||||
const longUserText = 'BEGIN_HEAD ' + 'x'.repeat(3000) + ' END_TAIL_MARKER';
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: longUserText }] },
|
||||
{ role: 'model', parts: [{ text: 'END_MODEL_MARKER' }] },
|
||||
];
|
||||
|
||||
let captured: string | null = null;
|
||||
const generateJson = vi.fn(async (opts: { contents: Content[] }) => {
|
||||
captured = opts.contents[0]?.parts?.[0]?.text ?? '';
|
||||
return { title: 'Long session' };
|
||||
});
|
||||
const config = {
|
||||
getFastModel: vi.fn(() => 'qwen-turbo'),
|
||||
getModel: vi.fn(() => 'qwen-plus'),
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
getChat: () => ({ getHistory: () => history }),
|
||||
})),
|
||||
getBaseLlmClient: vi.fn(() => ({ generateJson })),
|
||||
} as unknown as Config;
|
||||
|
||||
await tryGenerateSessionTitle(config, new AbortController().signal);
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured).toContain('END_MODEL_MARKER');
|
||||
expect(captured).not.toContain('BEGIN_HEAD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTitle', () => {
|
||||
it('strips leading and trailing markdown markers', () => {
|
||||
expect(sanitizeTitle('> **Fix login button**')).toBe('Fix login button');
|
||||
expect(sanitizeTitle('- Fix login button')).toBe('Fix login button');
|
||||
expect(sanitizeTitle('`Fix login button`')).toBe('Fix login button');
|
||||
});
|
||||
|
||||
it('strips trailing punctuation in ASCII and CJK', () => {
|
||||
expect(sanitizeTitle('Fix login button.')).toBe('Fix login button');
|
||||
expect(sanitizeTitle('修复登录按钮。')).toBe('修复登录按钮');
|
||||
expect(sanitizeTitle('修复登录按钮,')).toBe('修复登录按钮');
|
||||
});
|
||||
|
||||
it('normalizes internal whitespace', () => {
|
||||
expect(sanitizeTitle('Fix login button')).toBe('Fix login button');
|
||||
});
|
||||
|
||||
it('returns empty for noise-only input', () => {
|
||||
expect(sanitizeTitle('')).toBe('');
|
||||
expect(sanitizeTitle(' \n ')).toBe('');
|
||||
expect(sanitizeTitle('...')).toBe('');
|
||||
expect(sanitizeTitle('**')).toBe('');
|
||||
});
|
||||
|
||||
it('strips terminal escape sequences (ANSI, OSC-8, BEL)', () => {
|
||||
// SECURITY: title renders directly to terminal; escapes must not survive.
|
||||
expect(sanitizeTitle('\x1b[2J\x1b[HHello world')).toBe('Hello world');
|
||||
expect(sanitizeTitle('before\x07after')).toBe('before after');
|
||||
// OSC-8 hyperlink injection — opens a clickable link in supporting terminals.
|
||||
expect(sanitizeTitle('\x1b]8;;http://evil\x1b\\click\x1b]8;;\x1b\\')).toBe(
|
||||
'click',
|
||||
);
|
||||
// Null byte in value would otherwise corrupt the JSONL writer on some runtimes.
|
||||
expect(sanitizeTitle('a\x00b')).toBe('a b');
|
||||
});
|
||||
|
||||
it('drops orphaned surrogates after max-length truncation', () => {
|
||||
// Build a title that lands a surrogate pair exactly at the truncation boundary.
|
||||
const base = 'x'.repeat(199);
|
||||
// `"😀"` is a single emoji (two UTF-16 code units). After
|
||||
// slice(0, 200) we'd keep only the high surrogate.
|
||||
const title = base + '😀!';
|
||||
const sanitized = sanitizeTitle(title);
|
||||
// High surrogate must not linger on its own.
|
||||
expect(sanitized).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
|
||||
expect(sanitized.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
});
|
||||
274
packages/core/src/services/sessionTitle.ts
Normal file
274
packages/core/src/services/sessionTitle.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { stripTerminalControlSequences } from '../utils/terminalSafe.js';
|
||||
import { SESSION_TITLE_MAX_LENGTH } from './sessionService.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SESSION_TITLE');
|
||||
|
||||
const MAX_CONVERSATION_CHARS = 1000;
|
||||
const RECENT_MESSAGE_WINDOW = 20;
|
||||
|
||||
const TITLE_SYSTEM_PROMPT = `Generate a concise, sentence-case title (3-7 words) that captures what this programming-assistant session is about. Think of it as a git commit subject for the session.
|
||||
|
||||
Rules:
|
||||
- 3-7 words.
|
||||
- Sentence case: capitalize only the first word and proper nouns. NOT Title Case.
|
||||
- No trailing punctuation.
|
||||
- No quotes, backticks, or markdown.
|
||||
- Match the dominant language of the conversation (English or Chinese). For Chinese, treat as roughly 12-20 characters total; still no trailing punctuation.
|
||||
- Be specific about the user's actual goal — name the feature, bug, or subject area. Avoid vague "Code changes", "Help request", "Conversation".
|
||||
|
||||
Good examples:
|
||||
{"title": "Fix login button on mobile"}
|
||||
{"title": "Add OAuth authentication flow"}
|
||||
{"title": "Debug failing CI pipeline tests"}
|
||||
{"title": "重构用户鉴权中间件"}
|
||||
|
||||
Bad (too vague): {"title": "Code changes"}
|
||||
Bad (too long): {"title": "Investigate and fix the session title generation issue in the chat recording service"}
|
||||
Bad (wrong case): {"title": "Fix Login Button On Mobile"}
|
||||
Bad (trailing punctuation): {"title": "Fix login button."}
|
||||
|
||||
Return ONLY a JSON object with a single "title" key. No preamble, no reasoning, no closing remarks.`;
|
||||
|
||||
const TITLE_USER_PROMPT =
|
||||
'Generate the session title now. Populate the schema with a single short title string.';
|
||||
|
||||
const TITLE_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
description:
|
||||
'A concise sentence-case session title, 3-7 words, no trailing punctuation.',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
} as const;
|
||||
|
||||
const LEADING_MARKERS_RE = /^[\s>*\-#`"'_]+/;
|
||||
const TRAILING_MARKERS_RE = /[\s*`"'_]+$/;
|
||||
const TRAILING_PUNCT_RE = /[.!?。!?,,;;::]+$/;
|
||||
// Paired CJK brackets (e.g. `【Draft】Fix login`): strip as a whole so a
|
||||
// lone closing bracket doesn't dangle after a leading-char-class strip.
|
||||
const LEADING_PAIRED_BRACKETS_RE =
|
||||
/^\s*[「『【〈《][^」』】〉》]*[」』】〉》]\s*/;
|
||||
const TRAILING_PAIRED_BRACKETS_RE =
|
||||
/\s*[「『【〈《][^」』】〉》]*[」』】〉》]\s*$/;
|
||||
|
||||
/**
|
||||
* Reason a title generation didn't produce a usable title. Separated from
|
||||
* the success payload so callers (esp. the interactive `/rename --auto`
|
||||
* command) can surface actionable messages instead of a generic "could not
|
||||
* generate".
|
||||
*
|
||||
* - `no_fast_model`: config.getFastModel() returned undefined. User needs to
|
||||
* configure one via `/model --fast <name>`.
|
||||
* - `no_client`: BaseLlmClient or GeminiClient not yet initialized. Rare,
|
||||
* usually means the session hasn't authenticated yet.
|
||||
* - `empty_history`: the conversation has fewer than 2 turns of usable text.
|
||||
* User should send at least one message before asking for a title.
|
||||
* - `empty_result`: the model returned nothing parseable into a title. Often
|
||||
* means the model is too small or the conversation text is meaningless
|
||||
* (e.g., only tool calls).
|
||||
* - `aborted`: AbortSignal fired (user pressed Ctrl-C / new session / switch).
|
||||
* - `model_error`: the LLM call threw — rate limit, auth, network, etc.
|
||||
*/
|
||||
export type SessionTitleFailureReason =
|
||||
| 'no_fast_model'
|
||||
| 'no_client'
|
||||
| 'empty_history'
|
||||
| 'empty_result'
|
||||
| 'aborted'
|
||||
| 'model_error';
|
||||
|
||||
export type SessionTitleOutcome =
|
||||
| { ok: true; title: string; modelUsed: string }
|
||||
| { ok: false; reason: SessionTitleFailureReason };
|
||||
|
||||
/**
|
||||
* Generate a short (3-7 word, sentence-case) title for the current session
|
||||
* using the configured fast model. Best-effort — never throws.
|
||||
*
|
||||
* Returns a discriminated result so callers can either handle failures
|
||||
* generically (`if (!outcome.ok) return null`) or map failure reasons to
|
||||
* actionable messages (as `/rename --auto` does).
|
||||
*/
|
||||
export async function tryGenerateSessionTitle(
|
||||
config: Config,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<SessionTitleOutcome> {
|
||||
try {
|
||||
const model = config.getFastModel();
|
||||
if (!model) return { ok: false, reason: 'no_fast_model' };
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (!geminiClient) return { ok: false, reason: 'no_client' };
|
||||
|
||||
const fullHistory = geminiClient.getChat().getHistory();
|
||||
if (fullHistory.length < 2) return { ok: false, reason: 'empty_history' };
|
||||
|
||||
const dialog = filterToDialog(fullHistory);
|
||||
const recentHistory = takeRecentDialog(dialog, RECENT_MESSAGE_WINDOW);
|
||||
if (recentHistory.length === 0) {
|
||||
return { ok: false, reason: 'empty_history' };
|
||||
}
|
||||
|
||||
const conversationText = flattenToTail(
|
||||
recentHistory,
|
||||
MAX_CONVERSATION_CHARS,
|
||||
);
|
||||
if (!conversationText.trim()) return { ok: false, reason: 'empty_history' };
|
||||
|
||||
const baseLlmClient = config.getBaseLlmClient();
|
||||
if (!baseLlmClient) return { ok: false, reason: 'no_client' };
|
||||
|
||||
const result = await baseLlmClient.generateJson({
|
||||
model,
|
||||
systemInstruction: TITLE_SYSTEM_PROMPT,
|
||||
schema: TITLE_SCHEMA as unknown as Record<string, unknown>,
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `Conversation so far:\n${conversationText}\n\n${TITLE_USER_PROMPT}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
config: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 100,
|
||||
},
|
||||
abortSignal,
|
||||
promptId: 'session_title',
|
||||
// Titles are best-effort cosmetic metadata — one shot only, no long retry loop.
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
if (abortSignal.aborted) return { ok: false, reason: 'aborted' };
|
||||
|
||||
const rawTitle =
|
||||
typeof result?.['title'] === 'string' ? (result['title'] as string) : '';
|
||||
const title = sanitizeTitle(rawTitle);
|
||||
if (!title) return { ok: false, reason: 'empty_result' };
|
||||
|
||||
return { ok: true, title, modelUsed: model };
|
||||
} catch (err) {
|
||||
debugLogger.warn(
|
||||
`Session title generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (abortSignal.aborted) return { ok: false, reason: 'aborted' };
|
||||
return { ok: false, reason: 'model_error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw title string coming back from the schema-enforced JSON
|
||||
* call. The schema guarantees a string, but models routinely ignore the
|
||||
* "no markdown / no trailing punctuation" guidance, so we strip those
|
||||
* post-hoc. Exported for unit tests. Returns '' if nothing recoverable.
|
||||
*/
|
||||
export function sanitizeTitle(s: string): string {
|
||||
// SECURITY: strip terminal control sequences first. The title renders
|
||||
// directly in the picker — a model-returned ANSI/OSC-8 escape would
|
||||
// otherwise execute on every render. See `stripTerminalControlSequences`
|
||||
// for the coverage list.
|
||||
let t = stripTerminalControlSequences(s).trim();
|
||||
// Strip paired CJK bracket prefix/suffix first (as units) so we don't end
|
||||
// up with a lone closing bracket after the single-character strips below.
|
||||
t = t.replace(LEADING_PAIRED_BRACKETS_RE, '');
|
||||
t = t.replace(TRAILING_PAIRED_BRACKETS_RE, '');
|
||||
t = t.replace(LEADING_MARKERS_RE, '');
|
||||
t = t.replace(TRAILING_MARKERS_RE, '');
|
||||
t = t.replace(TRAILING_PUNCT_RE, '');
|
||||
t = t.replace(/\s+/g, ' ').trim();
|
||||
if (!t) return '';
|
||||
if (t.length > SESSION_TITLE_MAX_LENGTH) {
|
||||
t = t.slice(0, SESSION_TITLE_MAX_LENGTH).trim();
|
||||
// slice() can split a surrogate pair at the boundary — drop any
|
||||
// orphaned surrogate so the resulting string stays well-formed UTF-16.
|
||||
t = t.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, '');
|
||||
t = t.replace(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '');
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip tool calls, tool responses, and hidden reasoning from history; keep
|
||||
* only user prompts and the model's user-visible text. A single tool response
|
||||
* can be a 10K-token file dump that swamps the title-LLM with irrelevant detail.
|
||||
*/
|
||||
function filterToDialog(history: Content[]): Content[] {
|
||||
const out: Content[] = [];
|
||||
for (const msg of history) {
|
||||
if (msg.role !== 'user' && msg.role !== 'model') continue;
|
||||
const textParts = (msg.parts ?? []).filter(
|
||||
(part) =>
|
||||
typeof part?.text === 'string' &&
|
||||
part.text.trim() !== '' &&
|
||||
!part.thought &&
|
||||
!part.thoughtSignature,
|
||||
);
|
||||
if (textParts.length === 0) continue;
|
||||
out.push({ role: msg.role, parts: textParts });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the most recent N messages while preserving turn structure: never
|
||||
* start the slice on a model response that would dangle without its preceding
|
||||
* user message.
|
||||
*/
|
||||
function takeRecentDialog(history: Content[], windowSize: number): Content[] {
|
||||
if (history.length <= windowSize) return history;
|
||||
let start = history.length - windowSize;
|
||||
while (start < history.length && history[start]?.role !== 'user') {
|
||||
start++;
|
||||
}
|
||||
return history.slice(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten filtered dialog to labeled plain text, then tail-slice to the last
|
||||
* N characters. Tail (rather than head) captures what the session has become,
|
||||
* not just how it opened — e.g. a session that starts with "help me debug X"
|
||||
* but ends up refactoring Y should get a title about Y.
|
||||
*/
|
||||
function flattenToTail(history: Content[], maxChars: number): string {
|
||||
const lines: string[] = [];
|
||||
for (const msg of history) {
|
||||
const role = msg.role === 'user' ? 'User' : 'Assistant';
|
||||
const text = (msg.parts ?? [])
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
if (!text) continue;
|
||||
lines.push(`${role}: ${text}`);
|
||||
}
|
||||
const joined = lines.join('\n');
|
||||
if (joined.length <= maxChars) return joined;
|
||||
let tail = joined.slice(-maxChars);
|
||||
// `.slice` on a UTF-16 code-unit boundary can strand a lone low-surrogate
|
||||
// at the start (if the slice cut through a CJK supplementary char or emoji).
|
||||
// JSON-serializing that to the LLM produces an invalid surrogate that some
|
||||
// providers reject with 400s, burning an attempt against the 3-cap for no
|
||||
// real reason. Drop the dangling surrogate so the payload is always
|
||||
// well-formed UTF-16.
|
||||
if (tail.length > 0) {
|
||||
const firstCode = tail.charCodeAt(0);
|
||||
if (firstCode >= 0xdc00 && firstCode <= 0xdfff) {
|
||||
tail = tail.slice(1);
|
||||
}
|
||||
}
|
||||
return tail;
|
||||
}
|
||||
|
|
@ -11,8 +11,10 @@ import path from 'node:path';
|
|||
import {
|
||||
extractJsonStringField,
|
||||
extractLastJsonStringField,
|
||||
extractLastJsonStringFields,
|
||||
LITE_READ_BUF_SIZE,
|
||||
readLastJsonStringFieldSync,
|
||||
readLastJsonStringFieldsSync,
|
||||
unescapeJsonString,
|
||||
} from './sessionStorageUtils.js';
|
||||
|
||||
|
|
@ -280,4 +282,243 @@ describe('sessionStorageUtils', () => {
|
|||
).toBe('last');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLastJsonStringFields', () => {
|
||||
it('returns undefined for every key when primary is absent', () => {
|
||||
const hit = extractLastJsonStringFields(
|
||||
'{"type":"user","message":"hi"}',
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({ customTitle: undefined, titleSource: undefined });
|
||||
});
|
||||
|
||||
it('extracts secondary field from the same line as the primary', () => {
|
||||
const text =
|
||||
'{"subtype":"custom_title","customTitle":"A","titleSource":"auto"}\n';
|
||||
const hit = extractLastJsonStringFields(
|
||||
text,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({ customTitle: 'A', titleSource: 'auto' });
|
||||
});
|
||||
|
||||
it('when primary appears on multiple lines, picks the latest and its own secondary', () => {
|
||||
const text = [
|
||||
'{"subtype":"custom_title","customTitle":"A","titleSource":"manual"}',
|
||||
'{"subtype":"custom_title","customTitle":"B","titleSource":"auto"}',
|
||||
'',
|
||||
].join('\n');
|
||||
const hit = extractLastJsonStringFields(
|
||||
text,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({ customTitle: 'B', titleSource: 'auto' });
|
||||
});
|
||||
|
||||
it('returns secondary=undefined when the winning line lacks it (legacy record)', () => {
|
||||
const text = '{"subtype":"custom_title","customTitle":"legacy"}\n';
|
||||
const hit = extractLastJsonStringFields(
|
||||
text,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({ customTitle: 'legacy', titleSource: undefined });
|
||||
});
|
||||
|
||||
it('never lets titleSource from an OLDER line leak into a NEWER primary match', () => {
|
||||
// Older record has both fields; newer record (wins) has only customTitle.
|
||||
// If the implementation did two separate scans, titleSource would leak
|
||||
// from the older line — the single-pass contract forbids this.
|
||||
const text = [
|
||||
'{"subtype":"custom_title","customTitle":"old","titleSource":"auto"}',
|
||||
'{"subtype":"custom_title","customTitle":"new"}',
|
||||
'',
|
||||
].join('\n');
|
||||
const hit = extractLastJsonStringFields(
|
||||
text,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({ customTitle: 'new', titleSource: undefined });
|
||||
});
|
||||
|
||||
it('respects lineContains — matches on non-tagged lines are ignored', () => {
|
||||
// A user message happens to contain a customTitle substring; the line
|
||||
// doesn't include "custom_title" so it's filtered out.
|
||||
const text = [
|
||||
'{"type":"user","message":"I want customTitle: \\"fake\\""}',
|
||||
'{"subtype":"custom_title","customTitle":"real","titleSource":"manual"}',
|
||||
'',
|
||||
].join('\n');
|
||||
const hit = extractLastJsonStringFields(
|
||||
text,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({ customTitle: 'real', titleSource: 'manual' });
|
||||
});
|
||||
|
||||
it('rejects a crash-truncated trailing record with no closing quote', () => {
|
||||
// A clean record is followed by a truncated partial write. A naive
|
||||
// implementation would pick the truncated line as "latest" and return
|
||||
// titleSource=undefined (since the line never got its source written).
|
||||
// We require both fields from the last VALID record.
|
||||
const text =
|
||||
'{"subtype":"custom_title","customTitle":"A","titleSource":"auto"}\n' +
|
||||
'{"subtype":"custom_title","customTitle":"B';
|
||||
const hit = extractLastJsonStringFields(
|
||||
text,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({ customTitle: 'A', titleSource: 'auto' });
|
||||
});
|
||||
|
||||
it('handles escaped quotes inside the primary value', () => {
|
||||
const text =
|
||||
'{"subtype":"custom_title","customTitle":"He said \\"hi\\"","titleSource":"manual"}\n';
|
||||
const hit = extractLastJsonStringFields(
|
||||
text,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
);
|
||||
expect(hit).toEqual({
|
||||
customTitle: 'He said "hi"',
|
||||
titleSource: 'manual',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLastJsonStringFieldsSync', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sst-readfields-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeFile(name: string, content: string): string {
|
||||
const p = path.join(tmpDir, name);
|
||||
fs.writeFileSync(p, content);
|
||||
return p;
|
||||
}
|
||||
|
||||
it('returns all-undefined for a missing file', () => {
|
||||
const p = path.join(tmpDir, 'nope.jsonl');
|
||||
expect(
|
||||
readLastJsonStringFieldsSync(
|
||||
p,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
),
|
||||
).toEqual({ customTitle: undefined, titleSource: undefined });
|
||||
});
|
||||
|
||||
it('returns the atomic pair when tail contains the match', () => {
|
||||
const p = writeFile(
|
||||
'tail.jsonl',
|
||||
'{"subtype":"custom_title","customTitle":"A","titleSource":"auto"}\n',
|
||||
);
|
||||
expect(
|
||||
readLastJsonStringFieldsSync(
|
||||
p,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
),
|
||||
).toEqual({ customTitle: 'A', titleSource: 'auto' });
|
||||
});
|
||||
|
||||
it('falls through to full-file scan when tail has no match and finds the pair', () => {
|
||||
// Primary+secondary near start, filler > LITE_READ_BUF_SIZE, nothing in tail.
|
||||
const header =
|
||||
'{"subtype":"custom_title","customTitle":"X","titleSource":"auto"}\n';
|
||||
const filler =
|
||||
'{"type":"user","message":"' + 'x'.repeat(LITE_READ_BUF_SIZE) + '"}\n';
|
||||
const p = writeFile('phase2.jsonl', header + filler);
|
||||
expect(
|
||||
readLastJsonStringFieldsSync(
|
||||
p,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
),
|
||||
).toEqual({ customTitle: 'X', titleSource: 'auto' });
|
||||
});
|
||||
|
||||
it('handles records straddling a Phase-2 chunk boundary', () => {
|
||||
// Goal: place the winning custom_title record so it begins in Phase-2
|
||||
// chunk N and ends in chunk N+1. That exercises the `carry` logic in
|
||||
// readLastJsonStringFieldsSync — without it, the partial first chunk
|
||||
// wouldn't contain the closing quote and the match would be missed.
|
||||
//
|
||||
// Layout:
|
||||
// [padA bytes..............][custom_title line][padB bytes................]
|
||||
// with padA + fixed header bytes + half of the title line <
|
||||
// LITE_READ_BUF_SIZE, and the rest of the title line spilling into
|
||||
// the next chunk. padB is >> tail window so Phase-2 fires (tail miss).
|
||||
const titleLine =
|
||||
'{"subtype":"custom_title","customTitle":"spanner","titleSource":"manual"}';
|
||||
const userHeader = '{"type":"user","message":"';
|
||||
const userFooter = '"}\n';
|
||||
// Position the title line so its middle lands on the chunk boundary.
|
||||
const padALen =
|
||||
LITE_READ_BUF_SIZE -
|
||||
userHeader.length -
|
||||
userFooter.length -
|
||||
Math.floor(titleLine.length / 2);
|
||||
const padA = 'a'.repeat(padALen);
|
||||
const padB = 'b'.repeat(LITE_READ_BUF_SIZE * 2);
|
||||
const p = writeFile(
|
||||
'straddle.jsonl',
|
||||
userHeader +
|
||||
padA +
|
||||
userFooter +
|
||||
titleLine +
|
||||
'\n' +
|
||||
userHeader +
|
||||
padB +
|
||||
userFooter,
|
||||
);
|
||||
expect(
|
||||
readLastJsonStringFieldsSync(
|
||||
p,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
),
|
||||
).toEqual({ customTitle: 'spanner', titleSource: 'manual' });
|
||||
});
|
||||
|
||||
it('does not let a truncated trailing partial record win', () => {
|
||||
const p = writeFile(
|
||||
'truncated.jsonl',
|
||||
'{"subtype":"custom_title","customTitle":"A","titleSource":"auto"}\n' +
|
||||
'{"subtype":"custom_title","customTitle":"B',
|
||||
);
|
||||
expect(
|
||||
readLastJsonStringFieldsSync(
|
||||
p,
|
||||
'customTitle',
|
||||
['titleSource'],
|
||||
'custom_title',
|
||||
),
|
||||
).toEqual({ customTitle: 'A', titleSource: 'auto' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,33 @@ import fs from 'node:fs';
|
|||
/** Size of the head/tail buffer for lite metadata reads (64KB). */
|
||||
export const LITE_READ_BUF_SIZE = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) we'll scan in the Phase-2 full-file fallback. Tail-
|
||||
* read fast path covers the realistic case (metadata is re-appended on every
|
||||
* session lifecycle event). A pathological / corrupt session file that's
|
||||
* tens of GB should NOT block the picker for minutes while we scan it all.
|
||||
* The session picker renders on the main event loop, so blocking I/O here
|
||||
* freezes the UI.
|
||||
*/
|
||||
export const MAX_FULL_SCAN_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Flags used when opening session files for metadata reads. `O_NOFOLLOW`
|
||||
* refuses to follow symlinks — defense in depth so a symlink planted in
|
||||
* `~/.qwen/tmp/<hash>/chats/` (by another local user or an extension with
|
||||
* filesystem access) can't redirect a metadata read to an unrelated file.
|
||||
* Falls back to plain read-only when the flag isn't available (e.g. Windows
|
||||
* doesn't expose O_NOFOLLOW; the constant is `undefined` there).
|
||||
*
|
||||
* Computed lazily so tests that stub out `fs` don't blow up at module-init
|
||||
* time trying to read `fs.constants.O_RDONLY`.
|
||||
*/
|
||||
function getReadOpenFlags(): number {
|
||||
const constants = fs.constants;
|
||||
if (!constants) return 0;
|
||||
return (constants.O_RDONLY ?? 0) | (constants.O_NOFOLLOW ?? 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON string field extraction — no full parse, works on truncated lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -63,6 +90,92 @@ export function extractJsonStringField(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like extractJsonStringField but finds the LAST well-formed occurrence of
|
||||
* `primaryKey` and returns every `otherKeys` value extracted from THAT SAME
|
||||
* line. Two separate `extractLastJsonStringField` calls can land on different
|
||||
* records when an older line contains only one of the fields — this function
|
||||
* guarantees the returned fields all come from the same record.
|
||||
*
|
||||
* Validation: a primary-key match counts only when its string value has a
|
||||
* proper closing quote. A crash-truncated trailing record (`"customTitle":"x`
|
||||
* with no closing `"`) is ignored — otherwise it could "win" the latest-match
|
||||
* race and cause the function to extract secondaries from a partial line
|
||||
* where they don't appear.
|
||||
*
|
||||
* When `lineContains` is provided, only lines containing that substring are
|
||||
* considered matches (same semantics as the single-field version).
|
||||
*/
|
||||
export function extractLastJsonStringFields(
|
||||
text: string,
|
||||
primaryKey: string,
|
||||
otherKeys: string[],
|
||||
lineContains?: string,
|
||||
): Record<string, string | undefined> {
|
||||
const out: Record<string, string | undefined> = { [primaryKey]: undefined };
|
||||
for (const k of otherKeys) out[k] = undefined;
|
||||
|
||||
const patterns = [`"${primaryKey}":"`, `"${primaryKey}": "`];
|
||||
|
||||
let bestPrimaryValue: string | undefined;
|
||||
let bestLineStart = -1;
|
||||
let bestLineEnd = -1;
|
||||
let bestOffset = -1;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const idx = text.indexOf(pattern, searchFrom);
|
||||
if (idx < 0) break;
|
||||
searchFrom = idx + pattern.length;
|
||||
|
||||
// Line-contains filter first (cheap)
|
||||
const lineStart = text.lastIndexOf('\n', idx) + 1;
|
||||
const eol = text.indexOf('\n', idx);
|
||||
const lineEnd = eol < 0 ? text.length : eol;
|
||||
if (lineContains) {
|
||||
const line = text.slice(lineStart, lineEnd);
|
||||
if (!line.includes(lineContains)) continue;
|
||||
}
|
||||
|
||||
// Validate the value: walk to a non-escaped closing quote. A truncated
|
||||
// trailing write (no closing quote before EOF) is rejected — this is
|
||||
// the guard that keeps crash-recovery safe.
|
||||
const valueStart = idx + pattern.length;
|
||||
let i = valueStart;
|
||||
let terminated = false;
|
||||
while (i < text.length) {
|
||||
if (text[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === '"') {
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (!terminated) continue;
|
||||
|
||||
// We accept this match; keep it if it's the latest so far.
|
||||
if (idx > bestOffset) {
|
||||
bestOffset = idx;
|
||||
bestLineStart = lineStart;
|
||||
bestLineEnd = lineEnd;
|
||||
bestPrimaryValue = unescapeJsonString(text.slice(valueStart, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestOffset < 0) return out;
|
||||
out[primaryKey] = bestPrimaryValue;
|
||||
const line = text.slice(bestLineStart, bestLineEnd);
|
||||
for (const k of otherKeys) {
|
||||
out[k] = extractJsonStringField(line, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like extractJsonStringField but finds the LAST occurrence.
|
||||
* Useful for fields that are appended (customTitle, aiTitle, etc.)
|
||||
|
|
@ -157,7 +270,7 @@ export function readLastJsonStringFieldSync(
|
|||
const fileSize = stats.size;
|
||||
if (fileSize === 0) return undefined;
|
||||
|
||||
fd = fs.openSync(filePath, 'r');
|
||||
fd = fs.openSync(filePath, getReadOpenFlags());
|
||||
|
||||
// Phase 1: tail window — fast path.
|
||||
const tailLength = Math.min(fileSize, LITE_READ_BUF_SIZE);
|
||||
|
|
@ -176,16 +289,18 @@ export function readLastJsonStringFieldSync(
|
|||
// to scan.
|
||||
if (tailOffset === 0) return undefined;
|
||||
|
||||
// Phase 2: stream the whole file and return the last hit. Scanning from
|
||||
// offset 0 (rather than [0, tailOffset)) avoids the edge case where a
|
||||
// single record straddles the Phase 1/Phase 2 boundary — duplicate work
|
||||
// on the tail bytes is harmless because we only care about the final
|
||||
// match.
|
||||
// Phase 2: stream the file up to MAX_FULL_SCAN_BYTES and return the last
|
||||
// hit. Scanning from offset 0 (rather than [0, tailOffset)) avoids the
|
||||
// edge case where a single record straddles the Phase 1/Phase 2 boundary
|
||||
// — duplicate work on the tail bytes is harmless because we only care
|
||||
// about the final match. The hard cap bounds worst-case latency for
|
||||
// pathologically large session files (which would freeze the picker).
|
||||
let lastHit: string | undefined;
|
||||
let readOffset = 0;
|
||||
let carry = '';
|
||||
while (readOffset < fileSize) {
|
||||
const toRead = Math.min(LITE_READ_BUF_SIZE, fileSize - readOffset);
|
||||
const scanLimit = Math.min(fileSize, MAX_FULL_SCAN_BYTES);
|
||||
while (readOffset < scanLimit) {
|
||||
const toRead = Math.min(LITE_READ_BUF_SIZE, scanLimit - readOffset);
|
||||
const buf = Buffer.alloc(toRead);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, toRead, readOffset);
|
||||
if (bytesRead === 0) break;
|
||||
|
|
@ -225,3 +340,105 @@ export function readLastJsonStringFieldSync(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link readLastJsonStringFieldSync} but extracts multiple fields from
|
||||
* the same matching line atomically (single file scan, consistent pair).
|
||||
*
|
||||
* The primary key determines the "winning" line (latest occurrence on a line
|
||||
* that also contains `lineContains`). Every other requested field is pulled
|
||||
* from that same line — never from an earlier or later record — so callers
|
||||
* get a consistent record snapshot. Useful when a record pairs a payload
|
||||
* field with its metadata (e.g. `customTitle` + `titleSource`).
|
||||
*
|
||||
* Missing fields (primary or secondary) appear in the returned object with
|
||||
* value `undefined`. I/O errors yield `undefined` for every key.
|
||||
*/
|
||||
export function readLastJsonStringFieldsSync(
|
||||
filePath: string,
|
||||
primaryKey: string,
|
||||
otherKeys: string[],
|
||||
lineContains?: string,
|
||||
): Record<string, string | undefined> {
|
||||
const emptyResult: Record<string, string | undefined> = {};
|
||||
emptyResult[primaryKey] = undefined;
|
||||
for (const k of otherKeys) emptyResult[k] = undefined;
|
||||
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSize = stats.size;
|
||||
if (fileSize === 0) return emptyResult;
|
||||
|
||||
fd = fs.openSync(filePath, getReadOpenFlags());
|
||||
|
||||
// Phase 1: tail window fast path.
|
||||
const tailLength = Math.min(fileSize, LITE_READ_BUF_SIZE);
|
||||
const tailOffset = fileSize - tailLength;
|
||||
const tailBuffer = Buffer.alloc(tailLength);
|
||||
const tailBytes = fs.readSync(fd, tailBuffer, 0, tailLength, tailOffset);
|
||||
if (tailBytes > 0) {
|
||||
const tailText = tailBuffer.toString('utf-8', 0, tailBytes);
|
||||
const hit = extractLastJsonStringFields(
|
||||
tailText,
|
||||
primaryKey,
|
||||
otherKeys,
|
||||
lineContains,
|
||||
);
|
||||
if (hit[primaryKey] !== undefined) return hit;
|
||||
}
|
||||
|
||||
if (tailOffset === 0) return emptyResult;
|
||||
|
||||
// Phase 2: stream the file up to MAX_FULL_SCAN_BYTES, track the latest
|
||||
// match. Hard cap bounds worst-case latency on pathological files.
|
||||
let latest: Record<string, string | undefined> | undefined;
|
||||
let readOffset = 0;
|
||||
let carry = '';
|
||||
const scanLimit = Math.min(fileSize, MAX_FULL_SCAN_BYTES);
|
||||
while (readOffset < scanLimit) {
|
||||
const toRead = Math.min(LITE_READ_BUF_SIZE, scanLimit - readOffset);
|
||||
const buf = Buffer.alloc(toRead);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, toRead, readOffset);
|
||||
if (bytesRead === 0) break;
|
||||
readOffset += bytesRead;
|
||||
const chunk = carry + buf.toString('utf-8', 0, bytesRead);
|
||||
const lastNewline = chunk.lastIndexOf('\n');
|
||||
if (lastNewline < 0) {
|
||||
carry = chunk;
|
||||
continue;
|
||||
}
|
||||
const complete = chunk.slice(0, lastNewline + 1);
|
||||
carry = chunk.slice(lastNewline + 1);
|
||||
|
||||
const hit = extractLastJsonStringFields(
|
||||
complete,
|
||||
primaryKey,
|
||||
otherKeys,
|
||||
lineContains,
|
||||
);
|
||||
if (hit[primaryKey] !== undefined) latest = hit;
|
||||
}
|
||||
if (carry) {
|
||||
const hit = extractLastJsonStringFields(
|
||||
carry,
|
||||
primaryKey,
|
||||
otherKeys,
|
||||
lineContains,
|
||||
);
|
||||
if (hit[primaryKey] !== undefined) latest = hit;
|
||||
}
|
||||
|
||||
return latest ?? emptyResult;
|
||||
} catch {
|
||||
return emptyResult;
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
packages/core/src/utils/terminalSafe.ts
Normal file
43
packages/core/src/utils/terminalSafe.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip the terminal control sequences from arbitrary text so the result can
|
||||
* safely render in a TTY without painting cursor moves, clearing the screen,
|
||||
* or injecting OSC-8 hyperlinks.
|
||||
*
|
||||
* Covers:
|
||||
* - OSC sequences (`\x1b]...\x07` or `\x1b]...\x1b\\`) — handled as whole
|
||||
* units so the ST/BEL terminator is also stripped.
|
||||
* - CSI sequences (`\x1b[...<letter>`) — the common "cursor/color/erase"
|
||||
* family.
|
||||
* - SS2/SS3 / DCS leaders (`\x1b[NOP]`).
|
||||
* - Any remaining C0/C1 control bytes plus DEL, flattened to a space. This
|
||||
* backstop means a bare `\x1b` that wasn't part of a recognized sequence
|
||||
* still can't execute — the terminal only interprets ESC followed by
|
||||
* specific bytes.
|
||||
*
|
||||
* Used for LLM-returned text that ends up in the session picker (titles);
|
||||
* without this, a compromised or prompt-injected fast model could paint on
|
||||
* the user's terminal on every render.
|
||||
*/
|
||||
export function stripTerminalControlSequences(s: string): string {
|
||||
// These regexes deliberately match control characters; the whole point of
|
||||
// this module is to neutralize them. The no-control-regex rule is
|
||||
// suppressed per-line rather than file-wide so any future additions still
|
||||
// opt in explicitly.
|
||||
return (
|
||||
s
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, ' ')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[[\d;?]*[a-zA-Z]/g, ' ')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b[NOP]/g, ' ')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue