Skip to content

feat(session): auto-title sessions via fast model, add /rename --auto#3540

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
wenshao:feat/session-auto-title-trigger
Apr 23, 2026
Merged

feat(session): auto-title sessions via fast model, add /rename --auto#3540
wenshao merged 3 commits into
QwenLM:mainfrom
wenshao:feat/session-auto-title-trigger

Conversation

@wenshao

@wenshao wenshao commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

TLDR

Auto-generate a 3-7 word sentence-case session title via the fast model after the first assistant turn. Persist as titleSource: 'auto' (distinguished from manual in the picker via dimmed color). Adds /rename --auto for user-triggered regeneration. Builds on #3093.

Where to start reviewing

The feature is ~280 lines; the rest is persistence plumbing and hardening. Suggested order:

  1. packages/core/src/services/sessionTitle.ts — the generator (prompt + schema + sanitize).
  2. packages/core/src/services/chatRecordingService.tsmaybeTriggerAutoTitle (the 6 guards + fire-and-forget IIFE) and the resume hydration in the constructor.
  3. packages/cli/src/ui/commands/renameCommand.ts/rename --auto UX and failure-reason mapping.
  4. Everything else is isolated additions (new util terminalSafe.ts, new extractor readLastJsonStringFieldsSync, SessionPicker 1-line dim conditional, tests).

Risk / compatibility

  • Back-compat for existing sessions: pre-change custom_title records (no titleSource field) are treated as undefined → rendered as manual. No migration, no rewrite. A user's /rename from a prior version is never silently reclassified.
  • No schema version bump required: CustomTitleRecordPayload.titleSource is an optional additive field.
  • Off switch: QWEN_DISABLE_AUTO_TITLE=1 disables the trigger without touching fastModel (so /rename --auto still works on explicit request).
  • No auto-title in non-interactive mode: qwen -p "..." and CI runs never spend fast-model tokens on a one-shot session.
  • No fast-model → no-op: if config.getFastModel() is falsy we skip entirely. We do NOT silently fall back to the main model (too expensive to be silent).
  • /rename <name> (explicit name) unchanged: writes the literal title as titleSource: 'manual'.
  • Bare /rename (no args) has one behavior change: the kebab-case generator now prefers fastModel over the main model (config.getFastModel() ?? config.getModel()), instead of always using the main model. Output is also run through stripTerminalControlSequences. Users without fastModel configured see no change; users with fastModel configured get a faster and cheaper call but potentially weaker on mixed-language summarization. Pinned by new tests in renameCommand.test.tsdescribe('bare /rename model selection'). To revert to the main model only, either unset fastModel entirely (affects other features) or use /rename <name> with an explicit name.

Dive Deeper

Trigger guards (in order)

  1. Skip if currentCustomTitle set — never overwrite manual / prior auto.
  2. autoTitleController !== undefined — one attempt at a time.
  3. autoTitleAttempts < 3 — cap prevents runaway retry on persistent failure. Retry across turns is needed because a first assistant turn can be pure tool-call with no user-visible text.
  4. config.isInteractive().
  5. !autoTitleDisabledByEnv().
  6. config.getFastModel().

Persistence

CustomTitleRecordPayload grows titleSource: 'auto' | 'manual'. On resume, ChatRecordingService rehydrates both currentCustomTitle AND currentTitleSource via the new SessionService.getSessionTitleInfo. Without this, finalize()'s re-append would rewrite auto as manual on every resume — a silent downgrade.

Cross-process safety

Two CLI tabs on the same JSONL can diverge in memory. Before appending an auto record, the IIFE re-reads the file via getSessionTitleInfo. If the file already shows source: 'manual' (another process's /rename won), bail and sync in-memory state. Cost: one 64KB tail read per successful generation.

/rename --auto

Explicit user regeneration — overwrites any existing title. Failure reasons map to actionable messages (no fast model, empty history, empty result, model error, aborted) rather than a generic "could not generate". /rename -- --literal-name is the sentinel for titles starting with --.

Defense in depth (one line each)

  • Terminal injection — new stripTerminalControlSequences util strips OSC-8 / CSI / SS2-3 / C0-C1-DEL. Both /rename --auto and bare /rename route through it before the JSONL or UI.
  • Truncated JSONLextractLastJsonStringFields requires a proper closing quote so a crash-truncated trailing record can't win the latest-match race.
  • Torn pair read — single-pass extractor guarantees customTitle and titleSource come from the same line.
  • UTF-16 surrogates — orphaned high/low surrogates are dropped after slice boundaries (title and prompt paths).
  • Symlinks — session reads open with O_NOFOLLOW (Windows no-op).
  • Pathological files — Phase-2 scan capped at 64MB; a corrupt multi-GB JSONL can't freeze the picker.
  • CJK bracket decorators【Draft】 Fix login has the paired bracket stripped as a unit.
  • lineContains tightening — matcher upgraded from substring 'custom_title' to '"subtype":"custom_title"' so user text containing the literal phrase can't shadow a real record.

End-to-end evidence (tmux captures from a live local CLI)

All of the following are verbatim captures against the built branch on Linux with a working fastModel configured.

1. Auto-title trigger fires after the first assistant turn

Sent: help me understand the login button bug on mobile. Assistant replied, then the background IIFE hit the fast model. After exit, the session JSONL shows the new system record:

{"uuid":"64a5703c-7283-437b-b6b3-d0acee156323",
 "type":"system","subtype":"custom_title",
 "systemPayload":{"customTitle":"Debug login button on mobile",
                   "titleSource":"auto"}}

3-7 words, sentence case, titleSource: auto. No other custom_title records in the file.

2. /rename --auto regenerates on demand (overwrites existing auto)

Terminal capture:

  > /rename --auto

  ● Session renamed to "排查移动端登录按钮问题"

─────────────────────────────────────────────────── 排查移动端登录按钮问题 ──
>   Type your message or @path/to/file

JSONL now has TWO custom_title records (latest wins); model picked Chinese because the conversation had switched languages — the prompt explicitly tells the model to match the dominant language.

{ ... "customTitle":"Debug login button on mobile","titleSource":"auto" }
{ ... "customTitle":"排查移动端登录按钮问题","titleSource":"auto" }

3. Unknown --flag shows the sentinel hint

  > /rename --my-flag-with-dashes

  ✕ Unknown flag "--my-flag-with-dashes". Supported: --auto.
    To use this as a literal name, run /rename -- --my-flag-with-dashes.

4. /rename -- --literal-name sentinel lets dashes through

  > /rename -- --my-literal-name

  ● Session renamed to "--my-literal-name"

──────────────────────────────────────────────────────── --my-literal-name ──

JSONL: "customTitle":"--my-literal-name","titleSource":"manual".

5. /rename --auto some-name is rejected

  > /rename --auto some-name

  ✕ /rename --auto does not take a name. Use /rename <name> to set a name yourself.

6. QWEN_DISABLE_AUTO_TITLE=1 skips the trigger entirely

Same prompt as test 1 but with the env var set. After exit:

$ grep SESSION_TITLE /root/.qwen/debug/<sid>.txt
(no output)

$ grep custom_title /root/.qwen/projects/<proj>/chats/<sid>.jsonl
(no output)

Main conversation is unaffected (assistant reply still recorded, 2+26 etc.).

7. Error path with a broken fast model

When the configured fastModel is unreachable (e.g., belongs to a different provider), the auto-title code path still runs but fails cleanly:

  • Debug log: [WARN] [SESSION_TITLE] Session title generation failed: Failed to generate JSON content: 502 unknown provider for model qwen3.5-flash
  • No custom_title record is written (no dirty data on disk).
  • Main conversation continues unaffected.
  • /rename --auto surfaces a user-visible error: ✕ The fast model could not generate a title (rate limit, auth, or network error). Check debug log or try again.

Stack trace from the debug log confirms the call went through tryGenerateSessionTitleBaseLlmClient.generateJsonretryWithBackoff → OpenAI pipeline, and that maxAttempts: 1 short-circuited the retry loop.

8. Session picker renders the auto title

After test 2, /resume shows:

  │ › 排查移动端登录按钮问题                              │
  │   1 minute ago · 11 messages                       │

When selected, accent color takes priority (legibility first); when non-selected, theme.text.secondary is applied by the isAutoTitle branch in SessionPicker.tsx.

Reviewer Test Plan

  1. Configure "fastModel": "<available-model-id>" in ~/.qwen/settings.json.
  2. cd /tmp/test-autotitle && qwen → send a real prompt → wait for the reply.
  3. Exit, inspect the session file:
    grep custom_title ~/.qwen/projects/-tmp-test-autotitle/chats/*.jsonl
    Expect one record with titleSource: "auto" and a 3-7 word sentence-case title.
  4. qwen --resume and select the session — the auto title should render dimmer than non-selected manual titles (if any).
  5. /rename my-manual → verify a new record appears with titleSource: "manual"; picker no longer dims it.
  6. /rename --auto → verify regeneration overwrites back to titleSource: "auto".
  7. /rename --my-flag → expect the sentinel-hint error message.
  8. QWEN_DISABLE_AUTO_TITLE=1 qwen → no custom_title record should appear.

Testing Matrix

🍏 🪟 🐧
npm run
npx
Docker
Podman - -
Seatbelt - -

Manually verified on Linux via tmux end-to-end (captures above). Unit tests (6054 core + 15 rename CLI) pass; CI should cover macOS/Windows.

Linked issues / bugs

Builds on #3093.

The /rename work in QwenLM#3093 generates kebab-case titles only when the user
explicitly runs `/rename` with no args; until they do, the session picker
shows the first user prompt (often truncated or misleading). This change
adds a sentence-case auto-title that fires once per session after the
first assistant turn, using the configured fast model.

New service: `packages/core/src/services/sessionTitle.ts` —
`tryGenerateSessionTitle(config, signal)` returns a discriminated outcome
(`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can
either handle failures generically or map reasons to actionable messages.
Prompt shape: 3-7 words, sentence case, good/bad examples including a
CJK row, JSON schema enforced via `baseLlmClient.generateJson`.
`maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight
rate limits.

Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after
`recordAssistantTurn`. Fire-and-forget promise, guarded by:

- `currentCustomTitle` — don't overwrite any existing title.
- `autoTitleController` doubles as in-flight flag; a second turn while
  the first is still pending is a no-op.
- `autoTitleAttempts` cap of 3 — the first assistant turn may be a
  pure tool-call with no user-visible text; retry for a handful of
  turns until a title lands. Cap bounds total waste.
- `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto-
  titles; spending fast-model tokens on a one-shot session is waste.
- `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out.
- `config.getFastModel()` falsy — skip entirely rather than falling
  back to the main model; auto-titling on main-model tokens is too
  expensive to be silent.

Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' |
'manual'` field. Absent on pre-change records (treated as `undefined`
→ manual, safe default so a user's pre-upgrade `/rename` is never
silently reclassified). `SessionPicker` renders `titleSource === 'auto'`
titles in dim (secondary) color; manual stays full contrast. On resume,
the persisted source is rehydrated into `currentTitleSource` — without
this, finalize's re-append would rewrite an auto title as manual on
every resume cycle.

Cross-process manual-rename guard: when two CLI tabs target the same
JSONL, in-memory state can diverge. Before writing an auto record, the
IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a
`/rename` from another process landed as manual, bail and sync local
state — never clobber a deliberately-chosen manual title with a model
guess. Cost is one 64KB tail read per successful generation.

`finalize()` aborts the in-flight controller before re-appending the
title record. Session switch / shutdown doesn't have to wait on a slow
fast-model call.

New user-facing command: `/rename --auto` regenerates via the same
generator — explicit user trigger, overwrites whatever's there (manual
or auto) because the user asked. Errors route through
`autoFailureMessage(reason)` so `empty_history`, `model_error`,
`aborted`, etc. each get actionable guidance rather than a generic
"could not generate". `/rename -- --literal-name` is the sentinel for
titles that start with `--`; unknown `--flag` tokens error with a hint
pointing at the sentinel. Existing `/rename <name>` and bare `/rename`
(kebab-case via existing path) are unchanged, except the kebab path now
prefers fast model when available and runs its output through
`stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the
sentence-case path).

New shared util: `packages/core/src/utils/terminalSafe.ts` —
`stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI
(\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A
model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise
execute on every SessionPicker render; both sentence-case and kebab
paths now route titles through the helper before they reach the JSONL
or the UI.

Tail-read extractor: `extractLastJsonStringFields(text, primaryKey,
otherKeys, lineContains)` reads multiple fields from the same matching
line in a single pass. Two separate tail scans could return a mismatched
pair (primary from a newer record, secondary from an older one with only
the primary set); the new helper guarantees the pair is atomic. Validates
a proper closing quote on the primary value so a crash-truncated trailing
record can't win the latest-match race. `readLastJsonStringFieldsSync`
is its file-reading wrapper — same tail-window fast path and full-file
fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB`
cap so a corrupt multi-GB session file can't freeze the picker. Session
reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows
where the constant isn't exposed) — defense in depth against a symlink
planted in `~/.qwen/projects/<proj>/chats/`.

Character handling: `flattenToTail` on the LLM prompt drops a dangling
low surrogate after `slice(-1000)` — otherwise a CJK supplementary char
or emoji cut mid-pair produces invalid UTF-16 that some providers 400.
`sanitizeTitle` applies the same surrogate scrub after max-length trim,
and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so
a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char
strip. `lineContains` in the title reader is tightened from the loose
substring `'custom_title'` to `'"subtype":"custom_title"'` so user text
containing the literal `custom_title` can't shadow a real record.

Tests: 46 new unit tests across
- `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call
  filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets.
- `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix,
  in-flight guard, abort propagation on finalize, manual/auto/legacy
  resume symmetry, cross-process race, env opt-out, retry-after-
  transient.
- `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle
  boundary, truncated trailing record, lineContains, multi-field atom.
- `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel,
  unknown-flag hint, positional rejection, manual/SessionService
  fallbacks.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — gpt-5.4 via Qwen Code /review

Matches the session-recap design doc shape (Overview / Triggers /
Architecture / Prompt Design / History Filtering / Persistence /
Concurrency / Configuration / Observability / Out of Scope) and adds a
Security Hardening section unique to the title path — titles render
directly in the picker and persist in user-readable JSONL, so
LLM-returned control sequences are an attack surface the recap path
doesn't have.

Captures decisions a code-only reader has to reverse-engineer:

- Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop).
- Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call).
- Why the auto trigger does NOT fall back to the main model but
  session-recap does (auto-title fires on every turn; silently charging
  main-model tokens is a bill surprise).
- Why `titleSource: undefined` stays unwritten on legacy records (no
  rewrite risks silently reclassifying user intent).
- Why the cross-process re-read sits between the LLM await and the
  append (manual wins at both in-process and on-disk layers).
- Why `finalize()`'s abort tolerates a controller swap (in-flight
  identity check).
- Why JSON-schema function calling instead of tag extraction (avoid
  reasoning preamble bleed; cross-provider reliability).

Placed at docs/design/session-title/ alongside session-recap,
compact-mode, fork-subagent, and other per-feature design docs. No
sidebar index update required — the design folder is unindexed.
@wenshao wenshao requested a review from tanzhenxin April 23, 2026 03:18
@tanzhenxin tanzhenxin requested a review from qqqys April 23, 2026 03:21
@qqqys

qqqys commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

One thing worth tightening before merge — the bare /rename model change:

The change

Pre-PR, bare /rename always used config.getModel() (main model). Post-PR (packages/cli/src/ui/commands/renameCommand.ts:64):

const model = config.getFastModel() ?? config.getModel();

If a user has fastModel configured (for recap, compression, forked agent, etc.), bare /rename now silently routes through it.

Why it matters

fastModel shipped before this PR for other features. Users who configured it did not sign up for /rename to piggyback on it. Summarizing 20 turns into 2-4 kebab-case words isn't free — fast models can be noticeably worse on this kind of mixed-language summarization. And QWEN_DISABLE_AUTO_TITLE=1 doesn't revert this behavior; the only way back to pre-PR is unsetting fastModel entirely, which breaks other features.

The PR body contradicts itself

Existing /rename and /rename <name> behavior unchanged. The no-arg kebab-case path is preserved; it now prefers the fast model when available

These two clauses can't both be true. /rename <name> is unchanged; bare /rename isn't.

Suggested minimum fixes (non-blocking)

  1. Update the PR body + CHANGELOG to call out the bare /rename model change explicitly, instead of claiming "unchanged".
  2. Add a test that pins the model choice. Today the bare-/rename tests in renameCommand.test.ts (lines 51, 77) mock getHistory to [], so execution exits before the getFastModel() ?? getModel() line — the model selection has zero test coverage and is prone to silent regression either way.

Rest of the PR looks solid; the defensive layering in the title extractor and the single-pass readLastJsonStringFieldsSync are well done.

@qqqys

qqqys commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

One more note — asymmetric cross-process guard in the resume path (non-blocking, follow-up):

maybeTriggerAutoTitle at chatRecordingService.ts:558-573 correctly re-reads the on-disk title before appending an auto record, so a /rename from another CLI tab wins. Good.

The resume path in the constructor (chatRecordingService.ts:288-298) doesn't have the same guard:

if (config.getResumedSessionData()) {
  const info = sessionService.getSessionTitleInfo(config.getSessionId());
  this.currentCustomTitle = info.title;
  this.currentTitleSource = info.source;
  this.finalize();   // re-append to EOF, no re-read in between
}

Scenario: Tab A has session S open; Tab B opens the same S via /resume; Tab A runs /rename my-new-name; Tab B's constructor (which read info before A's rename landed) calls finalize() which appends the stale title back to EOF. Tail-read on next picker load returns the stale title.

Windows are small but the tests don't cover it: sessionService.rename.test.ts and autoTitle.test.ts both stop short of the two-process resume race.

Suggested follow-up

Mirror the maybeTriggerAutoTitle pattern: in finalize() (or just the resume branch), re-read getSessionTitleInfo before the re-append and bail if the on-disk state diverges. Add a test that writes a custom_title record to the mock file between the initial read and the finalize call, and asserts the re-append is skipped.

Happy to pick this up as a follow-up — @qqqys will own it after this PR merges. Overall LGTM! Nice work on the defensive layering (the "subtype":"custom_title" tightening and the single-pass readLastJsonStringFieldsSync are particularly well done).

@qqqys

qqqys commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

One open UX question for the author — worth confirming the intended behavior:

Should the auto-generated title be reflected in the UI immediately after the first-turn trigger fires?

Currently it isn't. Verified by running the feature end-to-end against a real session JSONL:

  • ChatRecordingService.maybeTriggerAutoTitle → success branch calls recordCustomTitle(outcome.title, 'auto') at chatRecordingService.ts:574.
  • recordCustomTitle (:705-725) writes the JSONL record and updates this.currentCustomTitle / this.currentTitleSource on the instance.
  • It does not push the new title to the React UI state. The sessionName state in AppContainer.tsx (used for topRightLabel in InputPrompt.tsx:1289) is only ever set from three places: the startup/resume read at AppContainer.tsx:330, renameCommand.ts:354, and useResumeCommand.ts:87. The auto-trigger path touches none of them.

Observed effect: in the running session, the user sees nothing change — no toast, no bottom-bar label update, no history-region info message. The generated title only surfaces on next launch, when AppContainer:330 reads the JSONL on startup, or in the /resume picker.

This is confirmed by two real session files on my machine (both on this branch, with fastModel configured, version: dev):

# One session where only the auto trigger fired — no manual /rename
grep custom_title .../da081c7c-....jsonl
→ 5 records, all titleSource: 'auto', title: "Qwen code 项目初始问候"

The auto-title was silently correct in the file, but I never saw it in the CLI while that session was running.

Prior art — how Claude Code handles this

Claude Code's interactive REPL wires the same fire-and-forget into a React state setter plus an OSC-based terminal-tab-title update (claude-code/src/screens/REPL.tsx:2694setHaikuTitle(title)useTerminalTitle(...)). The user sees the tab name change a few seconds into the session. This is the missing half here — we have the persistence side (which Claude Code actually doesn't do in interactive mode), but not the real-time UI feedback side.

Options

  1. Push to bottom-bar on success: have maybeTriggerAutoTitle call back into setSessionName (via a callback injected from the CLI layer, or an event bus on ChatRecordingService).
  2. Add an OSC terminal-tab-title update: mirror Claude Code's useTerminalTitle.
  3. Add a transient info message to the history region: ● Auto-titled: "..." — gives users a clear signal that the feature worked.
  4. Deliberately silent (current behavior): if the intent is that auto-titles are purely metadata for the picker and never a live affordance, that's a valid design choice — just worth stating explicitly.

Not a blocker — the feature is internally correct — but (1) or (3) would make a real UX difference. Happy to scope this into the same follow-up as the resume-finalize re-read (qqqys will own).

@wenshao — which of the options reflects the intended design? If (4) is by design, no change needed; if not, want me to pick this up in the follow-up?

Addresses reviewer feedback: the bare `/rename` model selection
(`config.getFastModel() ?? config.getModel()`) had no test pinning
it either way. Previous tests mocked `getHistory: []`, which exits
the function before the model is ever chosen, so a silent regression
to either direction (always-main or always-fast) would pass CI.

Two explicit cases now:
- fastModel set → `generateContent` called with `model: 'qwen-turbo'`.
- fastModel unset → `generateContent` called with `model: 'main-model'`.

The tests intentionally mock a non-empty history so the kebab path
reaches the generateContent call site instead of bailing on empty input.
@wenshao

wenshao commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the contradiction — you're right, I was trying to have it both ways. Fixed:

Updated PR body (Risk / compatibility section): replaced the "behavior unchanged" claim with an explicit callout that bare /rename now prefers fastModel over the main model, including the caveats (weaker on mixed-language summarization, QWEN_DISABLE_AUTO_TITLE=1 doesn't revert it, unsetting fastModel has blast radius).

Added tests in 917a33f88renameCommand.test.tsdescribe('bare /rename model selection'):

  • uses fastModel when configured — pins generateContent call with model: 'qwen-turbo'.
  • falls back to main model when fastModel is unset — pins with model: 'main-model'.

Both use a non-empty history so the kebab path actually reaches generateContent instead of bailing on empty input (which was the gap that let silent regressions slip through).

Confirmed locally: npx vitest run src/ui/commands/renameCommand.test.ts → 17 pass.

@wenshao

wenshao commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Valid finding — confirmed by re-reading the constructor path: the resume branch reads getSessionTitleInfo once, then finalize() re-appends to EOF unconditionally. Between the read and the append there's a window where another process's /rename can land, and we'd append the stale title over it.

Happy for you to pick this up as a follow-up. Sketch of the fix for when you get to it:

// in finalize(), before the re-append:
if (this.autoTitleController) {
  try { this.autoTitleController.abort(); } catch {}
}
if (!this.currentCustomTitle) return;
// NEW: re-read on-disk to detect cross-process change since we hydrated
try {
  const onDisk = this.config.getSessionService()
    .getSessionTitleInfo(this.config.getSessionId());
  if (onDisk.title && onDisk.title !== this.currentCustomTitle) {
    // Another process updated between our resume read and now — bail.
    this.currentCustomTitle = onDisk.title;
    this.currentTitleSource = onDisk.source;
    return;
  }
} catch { /* best-effort */ }
// ... existing re-append ...

Test: mock getSessionTitleInfo to return A on construction then B on finalize; assert finalize skips the re-append and syncs in-memory state to B.

@wenshao

wenshao commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the end-to-end verification, that's exactly the signal I didn't have.

The current behavior (silent, only visible on next launch) was a deliberate scope cut rather than a considered design — in tmux I'd noticed the picker was correct after exit and called it done. Your readout clarifies it's a real UX gap, not the intended final state.

My preference is option (3): transient info message in the history region. Rationale:

  • Option (1) (bottom-bar update) is invisible unless the user happens to be looking at the tag; easy to miss.
  • Option (2) (OSC tab title) only helps users whose terminal shows window titles prominently; a lot of tmux/screen users won't see it.
  • Option (3) is a visible-once signal that naturally confirms "the feature fired and got a title" — useful both as UX and as an implicit debug channel ("did it run or not?").
  • Option (4) (silent) is defensible but, as you note, asymmetric with the session-recap pattern which does surface inline.

Option (1) or (2) are nice additions on top, not alternatives — doing (3) first doesn't block them.

Please do pick this up in the follow-up alongside the constructor re-read guard. Happy to review.

@qqqys

qqqys commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Overall LGTM! 🎉

Solid piece of work — the defensive layering (single-pass readLastJsonStringFieldsSync, "subtype":"custom_title" line-marker tightening, UTF-16 surrogate handling, O_NOFOLLOW, 64MB scan cap) is genuinely well thought out, and the test coverage on the new paths is thorough.

Summary of open items from this review, all non-blocking — I'll (@qqqys) take them as a follow-up after this merges:

  1. Resume-finalize cross-process guard — mirror the maybeTriggerAutoTitle re-read pattern so a stale in-memory snapshot can't re-append an outdated title at construction time.
  2. Real-time UI feedback on auto-trigger — push the generated title to sessionName state (and/or terminal tab via OSC) so users see the feature work in the current session, not just next resume.

None of these block shipping. Great work @wenshao — happy to iterate on the above in a follow-up PR.

@wenshao wenshao merged commit d36f12c into QwenLM:main Apr 23, 2026
13 checks passed
TaimoorSiddiquiOfficial pushed a commit to TaimoorSiddiquiOfficial/HopCode that referenced this pull request Apr 23, 2026
…QwenLM#3540)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
chiga0 pushed a commit that referenced this pull request Apr 24, 2026
…#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.
mabry1985 pushed a commit to protoLabsAI/protoCLI that referenced this pull request May 3, 2026
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session
renaming/deletion + custom-title support. Skips the auto-title-via-LLM
piece (depends on un-ported gateway shape) and the vscode-ide-companion
files (deleted in our fork).

What's in:

- /rename: prompt for a custom session title; persisted via
  ChatRecordingService.recordCustomTitle and surfaced in the picker.
- /delete: opens a SessionPicker that calls
  SessionService.removeSession on selection.
- SessionListItem.customTitle field + readSessionTitleFromFile tail
  scanner on session file load.
- SessionService.renameSession / getSessionTitle /
  findSessionsByTitle.
- ACP extMethod handlers for renameSession + deleteSession.
- SessionStart restores session-name tag from the persisted custom
  title via useInitializationEffects.
- --resume now accepts UUID or title (validation moved to runtime).

Conflict resolution notes:

- Kept HEAD's bg-agent useEffect block; the upstream init useEffect
  was already extracted into useInitializationEffects, so the
  customTitle restore goes there with an optional setSessionName arg.
- Kept HEAD's rewind dialog; added the delete dialog as a sibling.
- Kept HEAD's voice/recap state; added sessionName/setSessionName to
  UIState. Dropped upstream's streamingResponseLengthRef +
  isReceivingContent (token-display PR QwenLM#3329, un-ported).
- Dropped upstream MemoryDialog import (auto-memory un-ported); kept
  the i18n t import for the Delete dialog title.

Tests: 29 new tests pass (rename/delete commands, customTitle
recording, sessionService rename/find). Resume tests still pass.

Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a
generateSessionTitle path through ContentGenerator that needs
adaptation to our gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mabry1985 pushed a commit to protoLabsAI/protoCLI that referenced this pull request May 3, 2026
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session
renaming/deletion + custom-title support. Skips the auto-title-via-LLM
piece (depends on un-ported gateway shape) and the vscode-ide-companion
files (deleted in our fork).

What's in:

- /rename: prompt for a custom session title; persisted via
  ChatRecordingService.recordCustomTitle and surfaced in the picker.
- /delete: opens a SessionPicker that calls
  SessionService.removeSession on selection.
- SessionListItem.customTitle field + readSessionTitleFromFile tail
  scanner on session file load.
- SessionService.renameSession / getSessionTitle /
  findSessionsByTitle.
- ACP extMethod handlers for renameSession + deleteSession.
- SessionStart restores session-name tag from the persisted custom
  title via useInitializationEffects.
- --resume now accepts UUID or title (validation moved to runtime).

Conflict resolution notes:

- Kept HEAD's bg-agent useEffect block; the upstream init useEffect
  was already extracted into useInitializationEffects, so the
  customTitle restore goes there with an optional setSessionName arg.
- Kept HEAD's rewind dialog; added the delete dialog as a sibling.
- Kept HEAD's voice/recap state; added sessionName/setSessionName to
  UIState. Dropped upstream's streamingResponseLengthRef +
  isReceivingContent (token-display PR QwenLM#3329, un-ported).
- Dropped upstream MemoryDialog import (auto-memory un-ported); kept
  the i18n t import for the Delete dialog title.

Tests: 29 new tests pass (rename/delete commands, customTitle
recording, sessionService rename/find). Resume tests still pass.

Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a
generateSessionTitle path through ContentGenerator that needs
adaptation to our gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mabry1985 added a commit to protoLabsAI/protoCLI that referenced this pull request May 3, 2026
…) (#218)

* feat(session): port /rename and /delete with custom titles (QwenLM#3093)

Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session
renaming/deletion + custom-title support. Skips the auto-title-via-LLM
piece (depends on un-ported gateway shape) and the vscode-ide-companion
files (deleted in our fork).

What's in:

- /rename: prompt for a custom session title; persisted via
  ChatRecordingService.recordCustomTitle and surfaced in the picker.
- /delete: opens a SessionPicker that calls
  SessionService.removeSession on selection.
- SessionListItem.customTitle field + readSessionTitleFromFile tail
  scanner on session file load.
- SessionService.renameSession / getSessionTitle /
  findSessionsByTitle.
- ACP extMethod handlers for renameSession + deleteSession.
- SessionStart restores session-name tag from the persisted custom
  title via useInitializationEffects.
- --resume now accepts UUID or title (validation moved to runtime).

Conflict resolution notes:

- Kept HEAD's bg-agent useEffect block; the upstream init useEffect
  was already extracted into useInitializationEffects, so the
  customTitle restore goes there with an optional setSessionName arg.
- Kept HEAD's rewind dialog; added the delete dialog as a sibling.
- Kept HEAD's voice/recap state; added sessionName/setSessionName to
  UIState. Dropped upstream's streamingResponseLengthRef +
  isReceivingContent (token-display PR QwenLM#3329, un-ported).
- Dropped upstream MemoryDialog import (auto-memory un-ported); kept
  the i18n t import for the Delete dialog title.

Tests: 29 new tests pass (rename/delete commands, customTitle
recording, sessionService rename/find). Resume tests still pass.

Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a
generateSessionTitle path through ContentGenerator that needs
adaptation to our gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: nudge PR conflict recomputation

---------

Co-authored-by: Automaker <automaker@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…QwenLM#3540)

* feat(session): auto-title sessions via fast model, add /rename --auto

The /rename work in QwenLM#3093 generates kebab-case titles only when the user
explicitly runs `/rename` with no args; until they do, the session picker
shows the first user prompt (often truncated or misleading). This change
adds a sentence-case auto-title that fires once per session after the
first assistant turn, using the configured fast model.

New service: `packages/core/src/services/sessionTitle.ts` —
`tryGenerateSessionTitle(config, signal)` returns a discriminated outcome
(`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can
either handle failures generically or map reasons to actionable messages.
Prompt shape: 3-7 words, sentence case, good/bad examples including a
CJK row, JSON schema enforced via `baseLlmClient.generateJson`.
`maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight
rate limits.

Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after
`recordAssistantTurn`. Fire-and-forget promise, guarded by:

- `currentCustomTitle` — don't overwrite any existing title.
- `autoTitleController` doubles as in-flight flag; a second turn while
  the first is still pending is a no-op.
- `autoTitleAttempts` cap of 3 — the first assistant turn may be a
  pure tool-call with no user-visible text; retry for a handful of
  turns until a title lands. Cap bounds total waste.
- `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto-
  titles; spending fast-model tokens on a one-shot session is waste.
- `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out.
- `config.getFastModel()` falsy — skip entirely rather than falling
  back to the main model; auto-titling on main-model tokens is too
  expensive to be silent.

Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' |
'manual'` field. Absent on pre-change records (treated as `undefined`
→ manual, safe default so a user's pre-upgrade `/rename` is never
silently reclassified). `SessionPicker` renders `titleSource === 'auto'`
titles in dim (secondary) color; manual stays full contrast. On resume,
the persisted source is rehydrated into `currentTitleSource` — without
this, finalize's re-append would rewrite an auto title as manual on
every resume cycle.

Cross-process manual-rename guard: when two CLI tabs target the same
JSONL, in-memory state can diverge. Before writing an auto record, the
IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a
`/rename` from another process landed as manual, bail and sync local
state — never clobber a deliberately-chosen manual title with a model
guess. Cost is one 64KB tail read per successful generation.

`finalize()` aborts the in-flight controller before re-appending the
title record. Session switch / shutdown doesn't have to wait on a slow
fast-model call.

New user-facing command: `/rename --auto` regenerates via the same
generator — explicit user trigger, overwrites whatever's there (manual
or auto) because the user asked. Errors route through
`autoFailureMessage(reason)` so `empty_history`, `model_error`,
`aborted`, etc. each get actionable guidance rather than a generic
"could not generate". `/rename -- --literal-name` is the sentinel for
titles that start with `--`; unknown `--flag` tokens error with a hint
pointing at the sentinel. Existing `/rename <name>` and bare `/rename`
(kebab-case via existing path) are unchanged, except the kebab path now
prefers fast model when available and runs its output through
`stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the
sentence-case path).

New shared util: `packages/core/src/utils/terminalSafe.ts` —
`stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI
(\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A
model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise
execute on every SessionPicker render; both sentence-case and kebab
paths now route titles through the helper before they reach the JSONL
or the UI.

Tail-read extractor: `extractLastJsonStringFields(text, primaryKey,
otherKeys, lineContains)` reads multiple fields from the same matching
line in a single pass. Two separate tail scans could return a mismatched
pair (primary from a newer record, secondary from an older one with only
the primary set); the new helper guarantees the pair is atomic. Validates
a proper closing quote on the primary value so a crash-truncated trailing
record can't win the latest-match race. `readLastJsonStringFieldsSync`
is its file-reading wrapper — same tail-window fast path and full-file
fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB`
cap so a corrupt multi-GB session file can't freeze the picker. Session
reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows
where the constant isn't exposed) — defense in depth against a symlink
planted in `~/.qwen/projects/<proj>/chats/`.

Character handling: `flattenToTail` on the LLM prompt drops a dangling
low surrogate after `slice(-1000)` — otherwise a CJK supplementary char
or emoji cut mid-pair produces invalid UTF-16 that some providers 400.
`sanitizeTitle` applies the same surrogate scrub after max-length trim,
and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so
a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char
strip. `lineContains` in the title reader is tightened from the loose
substring `'custom_title'` to `'"subtype":"custom_title"'` so user text
containing the literal `custom_title` can't shadow a real record.

Tests: 46 new unit tests across
- `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call
  filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets.
- `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix,
  in-flight guard, abort propagation on finalize, manual/auto/legacy
  resume symmetry, cross-process race, env opt-out, retry-after-
  transient.
- `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle
  boundary, truncated trailing record, lineContains, multi-field atom.
- `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel,
  unknown-flag hint, positional rejection, manual/SessionService
  fallbacks.

* docs(session): design doc for auto session titles

Matches the session-recap design doc shape (Overview / Triggers /
Architecture / Prompt Design / History Filtering / Persistence /
Concurrency / Configuration / Observability / Out of Scope) and adds a
Security Hardening section unique to the title path — titles render
directly in the picker and persist in user-readable JSONL, so
LLM-returned control sequences are an attack surface the recap path
doesn't have.

Captures decisions a code-only reader has to reverse-engineer:

- Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop).
- Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call).
- Why the auto trigger does NOT fall back to the main model but
  session-recap does (auto-title fires on every turn; silently charging
  main-model tokens is a bill surprise).
- Why `titleSource: undefined` stays unwritten on legacy records (no
  rewrite risks silently reclassifying user intent).
- Why the cross-process re-read sits between the LLM await and the
  append (manual wins at both in-process and on-disk layers).
- Why `finalize()`'s abort tolerates a controller swap (in-flight
  identity check).
- Why JSON-schema function calling instead of tag extraction (avoid
  reasoning preamble bleed; cross-provider reliability).

Placed at docs/design/session-title/ alongside session-recap,
compact-mode, fork-subagent, and other per-feature design docs. No
sidebar index update required — the design folder is unindexed.

* test(rename): pin model choice in bare /rename kebab path

Addresses reviewer feedback: the bare `/rename` model selection
(`config.getFastModel() ?? config.getModel()`) had no test pinning
it either way. Previous tests mocked `getHistory: []`, which exits
the function before the model is ever chosen, so a silent regression
to either direction (always-main or always-fast) would pass CI.

Two explicit cases now:
- fastModel set → `generateContent` called with `model: 'qwen-turbo'`.
- fastModel unset → `generateContent` called with `model: 'main-model'`.

The tests intentionally mock a non-empty history so the kebab path
reaches the generateContent call site instead of bailing on empty input.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants