feat(session): add rename, delete, and auto-title generation for session#3093
Conversation
…ions - Add /rename command with LLM auto-title generation when no args provided - Add /delete command to remove sessions from the session picker - Display session name tag embedded in input prompt top border - Restore session name on /resume and --resume <title> CLI flag - Support rename and delete via ACP extMethod for VSCode extension - Add rename/delete UI to WebUI SessionSelector with two-click delete confirmation - Fix parentUuid chain: custom_title records now correctly reference the previous record's UUID, preventing session history from appearing empty after rename - Add SESSION_FILE_PATTERN validation to all SessionService methods that construct file paths from sessionId (defense-in-depth against path traversal) - Fix fd leak in readCustomTitleFromFile with try/finally - Fix --resume <title> exit code (exit 1 when no match found) - Add project ownership checks to VSCode qwenSessionReader delete/rename Co-Authored-By: Qwen-Coder <noreply@qwen.com>
📋 Review SummaryThis PR introduces session rename, delete, and auto-title generation features across CLI, VSCode extension, and WebUI. The implementation is well-architected with proper security hardening, efficient file I/O using tail-read strategies, and comprehensive test coverage. Overall, this is a solid implementation with thoughtful design decisions around data persistence and parent chain integrity. 🔍 General FeedbackPositive aspects:
Architectural decisions:
Recurring patterns:
🎯 Specific Feedback🔴 Critical
🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
| * (32-36 hex characters, optionally with hyphens). | ||
| */ | ||
| const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; | ||
| /** Maximum number of lines to scan when looking for the first prompt text. */ |
There was a problem hiding this comment.
[P0 · 正确性] TAIL_READ_SIZE = 64KB 会导致长会话丢失自定义标题
readCustomTitleFromFile 只读文件最后 64KB。如果用户 /rename 后继续大量对话,custom_title 记录会被推到距文件末尾 >64KB 的位置,tail-read 就找不到它了。
64KB ≈ 600-700 条典型 chat record,长对话很容易超过。影响范围:
getSessionTitle()返回undefined→ CLI 标签消失listSessions()的customTitle丢失 → picker 不显示自定义名称findSessionsByTitle()依赖listSessions→--resume <title>查找失败
建议方案:
- 将 custom_title 同时写入独立 sidecar 文件(如
<sessionId>.title),读标题时优先查 sidecar - 或者
readCustomTitleFromFile未找到时 fallback 到全文扫描 - 或者改变追加策略:每次 rename 同时在文件末尾追加新记录(已经是这样),但读取时增大 buffer / 全文扫
There was a problem hiding this comment.
已修复。重构为 readHeadAndTailSync() 同时读取文件头尾各 64KB,readSessionTitleFromFile 先查 tail 再 fallback 到 head。另外 ChatRecordingService.finalize() 在 session 退出/切换时会重新 append title 到文件末尾,保证 title 始终在 tail 窗口内。
| }), | ||
| getSessionId: vi.fn().mockReturnValue('test-session-id'), | ||
| getSessionService: vi.fn().mockReturnValue({ | ||
| renameSession: vi.fn().mockResolvedValue(true), |
There was a problem hiding this comment.
[P1 · 测试] 断言值与代码实际行为不匹配,测试应该会 fail
测试期望空输入返回 'Please provide a name. Usage: /rename <name>'。但实际代码路径:
name = ''.trim()→''if (!name)→ 进入自动生成分支generateSessionTitle(config)→ mock 没有getGeminiClient/getContentGenerator→ catch 后返回null- 最终返回
'Could not generate a title. Usage: /rename <name>'
期望值应改为 'Could not generate a title. Usage: /rename <name>'。
下面 line 107 的 whitespace-only test 也有相同问题。
另外建议补充一个正向的 auto-generate 测试(mock LLM 返回生成标题的场景)。
There was a problem hiding this comment.
已修复。测试断言已更新,与实际代码行为一致。
| const title = params['title'] as string; | ||
| if (!sessionId || !SESSION_ID_RE.test(sessionId)) { | ||
| throw RequestError.invalidParams( | ||
| undefined, |
There was a problem hiding this comment.
[P1 · 安全] ACP renameSession 缺少 title 长度校验
CLI (renameCommand.ts) 限制了 MAX_TITLE_LENGTH = 200,SessionMessageHandler.ts 也做了 200 字符检查。但此处只验证了 title 非空和类型是 string,没有长度上限。
恶意 ACP 客户端可发送超长 title → 完整写入 JSONL → readCustomTitleFromFile 的 64KB 读取只拿到 title 的一部分 → JSON.parse 失败 → 标题丢失。
建议加长度检查:
if (title.length > 200) {
throw RequestError.invalidParams(undefined, 'Title too long (max 200 chars)');
}There was a problem hiding this comment.
已修复。acpAgent.ts 中 renameSession 现在检查 title.length > SESSION_TITLE_MAX_LENGTH,使用 core 导出的共享常量。
| } catch { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
[P1 · 代码质量] console.log 残留在生产代码中
console.log('[QwenSessionReader] Renaming session:', sessionId, title);
会在 VSCode 输出面板打印用户的 session title。应删除或改为 console.debug。
| */ | ||
| private readLastRecordUuid(filePath: string): string | null { | ||
| try { | ||
| const TAIL_SIZE = 64 * 1024; |
There was a problem hiding this comment.
[P2 · 代码质量] readLastRecordUuid 在两个包中完全重复
sessionService.ts 和 qwenSessionReader.ts 的 readLastRecordUuid 逻辑完全一致(64KB tail-read + 反向遍历 + JSON.parse)。readCustomTitleFromFile 的 buffer 读取模式也类似。
建议抽取到 packages/core 的共享工具函数,如 readTailLines(filePath, maxBytes): string[]。
There was a problem hiding this comment.
暂不修改。vscode-ide-companion 和 core 包之间目前没有直接依赖关系(ide companion 通过 ACP 协议与 CLI 通信),抽共享工具会引入跨包依赖。后续如果统一包结构时再一起处理。
| writeStderrLine( | ||
| `Multiple sessions found with title "${argv.resume}". Please select one:`, | ||
| ); | ||
| resolvedSessionId = await showResumeSessionPicker(); |
There was a problem hiding this comment.
[P2 · UX] 多个 title 匹配时 picker 显示所有 session
当 matches.length > 1 时,提示 Multiple sessions found with title "xxx",然后 showResumeSessionPicker() 显示全部 session。用户看到上百个 session 却不知道哪些匹配。
建议传入匹配列表做预过滤,或高亮匹配项。
There was a problem hiding this comment.
已修复。gemini.tsx 和 resumeCommand.ts 现在都将 matches 传给 picker,只显示匹配的 session。
| // Close dialog immediately. | ||
| closeDeleteDialog(); | ||
|
|
||
| // Prevent deleting the current session. |
There was a problem hiding this comment.
[P2 · UX] CLI /delete 没有确认步骤,删除不可恢复
WebUI/VSCode 有两步确认(点 delete → 再点 "Delete?"),但 CLI 的流程是 SessionPicker → 选中 → 直接 removeSession(fs.unlinkSync)。
用户可能误选 session 就被永久删除了。建议:
- 选中后 addItem 显示确认提示,要求二次输入确认
- 或者在
handleDelete前加一个 "Are you sure?" dialog
There was a problem hiding this comment.
暂不加。CLI 的 /delete 本身已经需要用户在 picker 中主动选择 session,这个交互已经是一个 explicit action。和 WebUI/VSCode 不同,CLI 没有误触风险(没有鼠标悬浮触发)。后续如果有用户反馈再考虑加确认。
| const [renamingSessionId, setRenamingSessionId] = useState<string | null>( | ||
| null, | ||
| ); | ||
| const [renameValue, setRenameValue] = useState(''); |
There was a problem hiding this comment.
[P2 · 一致性] 标题长度限制 200 在多处硬编码,没有共享常量
maxLength={200} 这个值分散在 4 个地方:
renameCommand.ts:MAX_TITLE_LENGTH = 200SessionMessageHandler.ts:trimmedTitle.length > 200SessionSelector.tsx:maxLength={200}- ACP 端:缺失(见另一条评论)
建议在 packages/core 定义共享常量 SESSION_TITLE_MAX_LENGTH,各处引用。
There was a problem hiding this comment.
core 层已抽出 SESSION_TITLE_MAX_LENGTH 常量,renameCommand.ts 和 acpAgent.ts 已引用。WebUI SessionSelector.tsx 和 VSCode SessionMessageHandler.ts 因为不直接依赖 core 包,用注释标注了对应常量。跨包引入常量代价较大,保持现状。
| return true; | ||
| } catch (error) { | ||
| console.error('[QwenSessionReader] Failed to delete session:', error); | ||
| return false; |
There was a problem hiding this comment.
[P2 · 一致性] VSCode rename record 缺少 gitBranch,且 version: 'vscode' 不是语义化版本
CLI 通过 ChatRecordingService.recordCustomTitle() 创建的 record 包含 gitBranch 和真实版本号。但 VSCode 路径创建的 record:
- 没有
gitBranch version: 'vscode'不是版本号
sessionService.ts:renameSession 也缺少 gitBranch。record 格式不一致可能给未来迁移/分析带来困惑。
There was a problem hiding this comment.
已修复。renameSession 现在包含 gitBranch: getGitBranch(cwd)。version: 'vscode' 保留,这是标识记录来源,不是 semver。
| gitBranch: firstRecord.gitBranch, | ||
| filePath, | ||
| messageCount, | ||
| customTitle: this.readCustomTitleFromFile(filePath), |
There was a problem hiding this comment.
[P2 · 性能] listSessions 为每个 session 同步调用 readCustomTitleFromFile
listSessions 遍历时为每个文件执行同步 I/O(openSync + readSync + closeSync)。100 个 session = 100 次额外同步磁盘读取,可能在 session picker 等交互场景造成 UI 卡顿。
建议考虑:
- 将标题读取与现有的
readLines调用合并(已经在读文件了) - 或改为 async I/O
- 或按需 lazy-load(仅对 viewport 内的 session 读取标题)
There was a problem hiding this comment.
已知问题,暂不改。readHeadAndTailSync 对单个文件的 I/O 耗时极短(两次 64KB read),默认分页 20 条 session 的场景下不会造成可感知的阻塞。异步化需要改造整个 listSessions 调用链,收益不大,后续有性能问题再优化。
| ); | ||
|
|
||
| const columns = process.stdout.columns || 80; | ||
| // Build the top border line: ─────── label ── |
There was a problem hiding this comment.
[P2 · Correctness] topRightLabel.length is wrong for non-ASCII characters — border will be misaligned
labelWidth = topRightLabel.length + 4 uses JavaScript's .length which counts UTF-16 code units, not terminal display columns. CJK characters (e.g. Chinese session names) occupy 2 terminal columns but .length returns 1, so the dash count will be too high and the border line will overflow/wrap.
Example: a label "修复登录" has .length === 4 but takes 8 terminal columns.
Since /rename accepts arbitrary user input (not just kebab-case), this is a real display bug for international users.
Fix: use a string-width library (e.g. string-width which is already commonly available in Ink-based projects) to calculate the actual display width:
import stringWidth from 'string-width';
const labelWidth = topRightLabel ? stringWidth(topRightLabel) + 4 : 0;There was a problem hiding this comment.
已修复。改用 stringWidth(topRightLabel) 替代 .length,正确处理 CJK 和 emoji 等宽字符。
| }), | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
[P2 · Robustness] recordCustomTitle silently swallows errors, but setSessionName is called unconditionally — UI/data inconsistency
recordCustomTitle() has an internal try-catch that swallows write errors (just logs to debug). But context.ui.setSessionName(name) on line 151 runs regardless of whether the file write succeeded. This means:
- Disk full / permission error → file write fails silently
- UI tag shows the new name → user thinks rename worked
- On next resume, the title is gone (never persisted)
Two options to fix:
- Make
recordCustomTitlereturn a boolean indicating success, and only callsetSessionNameon success - Or change
recordCustomTitleto rethrow, and wrap the call here in a try-catch
The current code is a silent data-loss scenario.
There was a problem hiding this comment.
已修复。recordCustomTitle() 返回 false 时提前返回错误信息,不再执行 setSessionName。
| <SessionPicker | ||
| sessionService={config.getSessionService()} | ||
| currentBranch={uiState.branchName} | ||
| onSelect={uiActions.handleDelete} |
There was a problem hiding this comment.
[P3 · UX] Delete picker shows the current active session as selectable
The SessionPicker shows all sessions including the current one. If the user selects the current session, handleDelete rejects it with an info message "Cannot delete the current active session." — but this happens after the picker closes.
Better UX: pass the current session ID to SessionPicker and visually disable or hide it in the delete view, so users don't waste a click on an un-deletable item. The resume picker doesn't have this problem because resuming the current session is a valid (albeit no-op) action.
There was a problem hiding this comment.
已修复。useDeleteCommand 会检查 sessionId === config.getSessionId(),选中当前 session 时显示提示而非删除。
| @@ -69,6 +69,9 @@ export interface BaseTextInputProps { | |||
| prefix?: React.ReactNode; | |||
| /** Border color for the input box. */ | |||
| borderColor?: string; | |||
There was a problem hiding this comment.
[P3 · Nit] Duplicate JSDoc comment
Two consecutive doc-comments for topRightLabel:
/** Label overlaid on the top border (right-aligned). */
/** Label rendered on the top border line (right-aligned). Plain string for width calculation. */
Keep only the second one.
…itle feature - Fix renameCommand.ts import path to use barrel export instead of deep path - Add setSessionName to mock CommandContext - Add getSessionTitle to SessionService mock in useResumeCommand tests - Update renameCommand tests for auto-generate title behavior - Update InputPrompt snapshots Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
| toggleVimEnabled, | ||
| setGeminiMdFileCount, | ||
| reloadCommands, | ||
| setSessionName: setSessionName ?? (() => {}), |
There was a problem hiding this comment.
[P1 · 正确性] 新 session 后旧的 title tag 不会被清掉
现在 sessionName 被挂进 ui 上下文了,但 ui.clear() 这里只清了 history/screen,没有一起 setSessionName(null)。
/clear 和 /new 会先 startNewSession(),然后走 context.ui.clear(),所以新会话仍然会显示上一条会话的自定义标题,直到再次 /rename 或 /resume。
建议把 sessionName 也纳入 clear/reset 路径,一起在这里清空,避免新会话带着旧标签。
There was a problem hiding this comment.
已修复。/clear handler 中增加了 setSessionName?.(null) 调用。
|
|
||
| if (matches.length > 1) { | ||
| // Multiple matches — show picker to let user choose | ||
| return { type: 'dialog', dialog: 'resume' }; |
There was a problem hiding this comment.
[P2 · 交互一致性] 多个同名 session 时,这里打开的是“全量 picker”,不是“匹配结果 picker”
findSessionsByTitle(arg) 已经把匹配集算出来了,但这里直接回退到通用 resume dialog,实际会展示所有 session。
这样用户输入 /resume my-title 且命中多个结果时,还得重新在全量列表里找一遍,甚至可能误选到不相关的 session。PR 描述里写的是“multiple sessions match, the interactive picker is shown”,按这个语义更像是应该只在匹配结果里二次选择。
建议把匹配集传给 picker,或者单独做一个 choose from matches 分支;gemini.tsx 的 --resume <title> 路径也有同样问题,最好共用一套实现。
There was a problem hiding this comment.
已修复。resumeCommand.ts 现在返回 matchedSessions: matches,picker 只展示匹配结果而非全量 session 列表。
yiliang114
left a comment
There was a problem hiding this comment.
整体方向是对的,session rename/delete/auto-title 这套功能也补得比较完整。
不过我这边又看到一个阻塞性的状态问题:新 session 之后旧的 title tag 还会残留在输入框上;另外 multiple title matches 的 resume 交互也还需要再收一下。加上前面已有的几个阻塞点,这轮我先挂 request changes,具体看 inline comments。
…finalize mechanism - Add sessionStorageUtils with extractLastJsonStringField() for fast string-level JSON field extraction without full parse - Add readHeadAndTailSync() to read first and last 64KB of session files - Replace readCustomTitleFromFile() with readSessionTitleFromFile() using head+tail dual-read (tail customTitle > head customTitle) - Add finalize() to ChatRecordingService as single entry point for re-appending session metadata on any session departure - Call finalize() on resume, session switch, and shutdown - Export sessionStorageUtils from core package Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…ple sessions Previously, multiple title matches opened the full session picker, forcing the user to re-find their session. Now the matched sessions are passed through as initialSessions to the picker, skipping the full listSessions() load and showing only the relevant results. Also clears sessionName on /clear so new sessions don't carry stale title tags from the previous session. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
topRightLabel.length counts UTF-16 code units, not terminal columns. CJK characters take 2 columns but .length returns 1, causing the border line to overflow. Use string-width for correct display width. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
- Add SESSION_TITLE_MAX_LENGTH shared constant in core, replace hardcoded 200 in CLI/ACP/VSCode/WebUI - Add title length validation to ACP renameSession endpoint - Make recordCustomTitle return boolean; renameCommand checks it before updating UI to prevent silent data loss - Add gitBranch to VSCode rename record for consistency with CLI - Remove misleading "enforce kebab-case" comment - Remove duplicate JSDoc on topRightLabel Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The static "Generating session name…" text gave no visual feedback that the operation was in progress. Cycle through ".", "..", "..." every 500ms so users can tell the LLM call is still running. Co-Authored-By: Qwen-Coder <noreply@qwen.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…sations Adds isSwitchingSession state and sessionLoadComplete message to show a loading transition while session history is being rehydrated via ACP. Co-Authored-By: Qwen-Coder <noreply@qwen.com>
…tate Prevents loading overlay from getting stuck indefinitely if sessionLoadComplete message is never received. Co-Authored-By: Qwen-Coder <noreply@qwen.com>
…eContains filter 1. Track global character offset across both pattern variants so the truly last match wins (previously the second pattern scan could overwrite a later match from the first pattern). 2. Add optional lineContains parameter to scope matches to lines containing a marker (e.g. "custom_title"), preventing false matches from user content that happens to include a "customTitle" field. Co-Authored-By: Qwen-Coder <noreply@qwen.com>
slashCommandActions (useMemo) depends on handleResume, but handleResume was declared after useSlashCommandProcessor so it could call setAwayRecapItem(null). useSlashCommandProcessor itself consumes slashCommandActions, closing a three-way cycle that tsc catches as TS2448 "used before declaration" once QwenLM#3478's AppContainer changes land in main and get auto-merged into open PRs. Move handleResume above slashCommandActions and route the recap clear through a ref that a later useEffect syncs with setAwayRecapItem. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>
4cac407 to
29fd0b8
Compare
Replace the head+tail dual-read with readLastJsonStringFieldSync: scan the tail first and return on hit, otherwise stream the whole file and return the last match. Closes the blind spot where a custom_title record landing between the head and tail windows would be missed on large session files. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com>
|
|
||
| case 'renameQwenSession': | ||
| await this.handleRenameQwenSession( | ||
| (data?.sessionId as string) || '', |
There was a problem hiding this comment.
[Critical] This PR introduces a compile-blocking TS4111 pattern in several changed VSCode/WebUI session files: properties from Record<string, unknown> / index-signature objects are accessed with dot notation (data.sessionId, data.title, session.id, etc.). Under this repo's TypeScript settings, these must use bracket notation or a stricter declared type.
| (data?.sessionId as string) || '', | |
| case 'renameQwenSession': | |
| await this.handleRenameQwenSession( | |
| (data?.['sessionId'] as string) || '', | |
| (data?.['title'] as string) || '', | |
| ); | |
| break; |
— gpt-5.4 via Qwen Code /review
There was a problem hiding this comment.
@wenshao 感谢review,这条 [Critical] 不成立,gpt-5.4 漏看了 package 级 tsconfig:
noPropertyAccessFromIndexSignature: true 只写在根 tsconfig.json 里,但 packages/vscode-ide-companion/tsconfig.json 和 packages/webui/tsconfig.json 既没extends 根 config,自己也没开这个 flag —— 这两个包的 strict 集没有 TS4111。
实测在 HEAD (c36d08dfb) 上:
npm run typecheck(覆盖 cli / core / sdk / webui 4 个 workspace)exit 0cd packages/vscode-ide-companion && npm run check-types(即tsc --noEmit)exit 0
如果后续要把根 tsconfig 的 strict 集对齐到这两个包,那是单独一个清理 PR 的事,不是本 PR 的 blocker。建议 dismiss 这条。
…#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.
…ion (#3093) * feat(session): add rename, delete, and auto-title generation for sessions - Add /rename command with LLM auto-title generation when no args provided - Add /delete command to remove sessions from the session picker - Display session name tag embedded in input prompt top border - Restore session name on /resume and --resume <title> CLI flag - Support rename and delete via ACP extMethod for VSCode extension - Add rename/delete UI to WebUI SessionSelector with two-click delete confirmation - Fix parentUuid chain: custom_title records now correctly reference the previous record's UUID, preventing session history from appearing empty after rename - Add SESSION_FILE_PATTERN validation to all SessionService methods that construct file paths from sessionId (defense-in-depth against path traversal) - Fix fd leak in readCustomTitleFromFile with try/finally - Fix --resume <title> exit code (exit 1 when no match found) - Add project ownership checks to VSCode qwenSessionReader delete/rename Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(session): fix broken imports and missing mocks from rename/auto-title feature - Fix renameCommand.ts import path to use barrel export instead of deep path - Add setSessionName to mock CommandContext - Add getSessionTitle to SessionService mock in useResumeCommand tests - Update renameCommand tests for auto-generate title behavior - Update InputPrompt snapshots Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(session): add head+tail dual-read, string-level extraction, and finalize mechanism - Add sessionStorageUtils with extractLastJsonStringField() for fast string-level JSON field extraction without full parse - Add readHeadAndTailSync() to read first and last 64KB of session files - Replace readCustomTitleFromFile() with readSessionTitleFromFile() using head+tail dual-read (tail customTitle > head customTitle) - Add finalize() to ChatRecordingService as single entry point for re-appending session metadata on any session departure - Call finalize() on resume, session switch, and shutdown - Export sessionStorageUtils from core package Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): show filtered picker when /resume <title> matches multiple sessions Previously, multiple title matches opened the full session picker, forcing the user to re-find their session. Now the matched sessions are passed through as initialSessions to the picker, skipping the full listSessions() load and showing only the relevant results. Also clears sessionName on /clear so new sessions don't carry stale title tags from the previous session. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ui): use stringWidth for CJK-safe border alignment in input prompt topRightLabel.length counts UTF-16 code units, not terminal columns. CJK characters take 2 columns but .length returns 1, causing the border line to overflow. Use string-width for correct display width. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): address remaining PR #3093 review feedback - Add SESSION_TITLE_MAX_LENGTH shared constant in core, replace hardcoded 200 in CLI/ACP/VSCode/WebUI - Add title length validation to ACP renameSession endpoint - Make recordCustomTitle return boolean; renameCommand checks it before updating UI to prevent silent data loss - Add gitBranch to VSCode rename record for consistency with CLI - Remove misleading "enforce kebab-case" comment - Remove duplicate JSDoc on topRightLabel Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ui): add animated dots to session name generation loading indicator The static "Generating session name…" text gave no visual feedback that the operation was in progress. Cycle through ".", "..", "..." every 500ms so users can tell the LLM call is still running. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * feat(cli): add /tag as alias for /rename command Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(vscode): add loading overlay when switching to historical conversations Adds isSwitchingSession state and sessionLoadComplete message to show a loading transition while session history is being rehydrated via ACP. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(vscode): add 15s timeout fallback for session switching loading state Prevents loading overlay from getting stuck indefinitely if sessionLoadComplete message is never received. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(core): fix extractLastJsonStringField offset tracking and add lineContains filter 1. Track global character offset across both pattern variants so the truly last match wins (previously the second pattern scan could overwrite a later match from the first pattern). 2. Add optional lineContains parameter to scope matches to lines containing a marker (e.g. "custom_title"), preventing false matches from user content that happens to include a "customTitle" field. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * chore(cli): add i18n import to DialogManager Co-Authored-By: Qwen-Coder <noreply@qwen.ai> * fix(vscode): align currentConversationId with webview on fallback restore When session/load falls back to creating a fresh ACP session, backend was tracking the new ACP id while the webview still viewed the archived sessionId. That desync caused delete/rename/title-update to target the wrong session during the fallback window, and prevented the post-first- message sync path from firing because the two ids were pre-aligned. Keep currentConversationId pointing at the archived sessionId until the existing stream-end sync flips both sides to the live ACP id on the first user message. Matches the pattern already used by the offline branch. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(core): exhaustive scan in findSessionsByTitle to avoid mtime-boundary misses listSessions() paginates with an mtime-only cursor and strict `<` filter. When several session files share the same mtime across a page boundary, the next page's filter drops them, so --resume <title> could silently miss valid matches. Scan all session files directly for title lookup, with filename as a stable tie-breaker. Also check the (cheap) custom title before the full hydration pass (first-record read, project filter, message count, prompt extraction) so non-matching sessions skip the extra I/O. listSessions() itself is left alone: its cursor crosses ACP/webview package boundaries as a number and this edge case only affects UI display order, not data loss. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(acp): plumb listSessions page size through _meta VSCode companion passes `size` to acpConnection.listSessions, but the ACP spec's ListSessionsRequest schema has no `size` field, so the SDK's zod validator strips it before the agent handler sees it. The agent then only forwarded `cursor` to SessionService.listSessions, silently ignoring the caller's page-size intent. Carry page size through `_meta.size` on both sides, matching the pattern already used for other Qwen Code ACP extensions (e.g. the filesystem service's `_meta.bom` / `_meta.encoding`). `_meta` is typed as an open record in the ACP schema, so extra keys survive validation. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(webui): avoid unintended rename when canceling with Escape The rename input auto-submits on blur, and pressing Escape also triggers blur (via setRenamingSessionId(null) unmounting the input). Because state updates are async, the blur handler's handleRenameSubmit could still read the pre-Escape renameValue from its closure and call onRenameSession, turning a cancel into an accidental rename. Track cancellation via an isCancelingRenameRef flag: set it in the Escape branch, and have onBlur short-circuit when the flag is true, then reset it. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(vscode-ide-companion): clear switch timeout on unmount The 15s session-switch fallback timer was only cleared on the next call to setIsSwitchingSession. If the webview is torn down mid-switch, the timer stays alive and later fires setIsSwitchingSessionRaw(false) on an unmounted hook. Add a useEffect cleanup to clear any pending timer on unmount. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(cli): break handleResume/slashCommandActions circular dep slashCommandActions (useMemo) depends on handleResume, but handleResume was declared after useSlashCommandProcessor so it could call setAwayRecapItem(null). useSlashCommandProcessor itself consumes slashCommandActions, closing a three-way cycle that tsc catches as TS2448 "used before declaration" once #3478's AppContainer changes land in main and get auto-merged into open PRs. Move handleResume above slashCommandActions and route the recap clear through a ref that a later useEffect syncs with setAwayRecapItem. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(core): scan full file when title is not in tail window Replace the head+tail dual-read with readLastJsonStringFieldSync: scan the tail first and return on hit, otherwise stream the whole file and return the last match. Closes the blind spot where a custom_title record landing between the head and tail windows would be missed on large session files. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <noreply@qwen.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen-Coder <noreply@qwen.ai> Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
…#3540) * feat(session): auto-title sessions via fast model, add /rename --auto The /rename work in #3093 generates kebab-case titles only when the user explicitly runs `/rename` with no args; until they do, the session picker shows the first user prompt (often truncated or misleading). This change adds a sentence-case auto-title that fires once per session after the first assistant turn, using the configured fast model. New service: `packages/core/src/services/sessionTitle.ts` — `tryGenerateSessionTitle(config, signal)` returns a discriminated outcome (`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can either handle failures generically or map reasons to actionable messages. Prompt shape: 3-7 words, sentence case, good/bad examples including a CJK row, JSON schema enforced via `baseLlmClient.generateJson`. `maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight rate limits. Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after `recordAssistantTurn`. Fire-and-forget promise, guarded by: - `currentCustomTitle` — don't overwrite any existing title. - `autoTitleController` doubles as in-flight flag; a second turn while the first is still pending is a no-op. - `autoTitleAttempts` cap of 3 — the first assistant turn may be a pure tool-call with no user-visible text; retry for a handful of turns until a title lands. Cap bounds total waste. - `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto- titles; spending fast-model tokens on a one-shot session is waste. - `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out. - `config.getFastModel()` falsy — skip entirely rather than falling back to the main model; auto-titling on main-model tokens is too expensive to be silent. Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' | 'manual'` field. Absent on pre-change records (treated as `undefined` → manual, safe default so a user's pre-upgrade `/rename` is never silently reclassified). `SessionPicker` renders `titleSource === 'auto'` titles in dim (secondary) color; manual stays full contrast. On resume, the persisted source is rehydrated into `currentTitleSource` — without this, finalize's re-append would rewrite an auto title as manual on every resume cycle. Cross-process manual-rename guard: when two CLI tabs target the same JSONL, in-memory state can diverge. Before writing an auto record, the IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a `/rename` from another process landed as manual, bail and sync local state — never clobber a deliberately-chosen manual title with a model guess. Cost is one 64KB tail read per successful generation. `finalize()` aborts the in-flight controller before re-appending the title record. Session switch / shutdown doesn't have to wait on a slow fast-model call. New user-facing command: `/rename --auto` regenerates via the same generator — explicit user trigger, overwrites whatever's there (manual or auto) because the user asked. Errors route through `autoFailureMessage(reason)` so `empty_history`, `model_error`, `aborted`, etc. each get actionable guidance rather than a generic "could not generate". `/rename -- --literal-name` is the sentinel for titles that start with `--`; unknown `--flag` tokens error with a hint pointing at the sentinel. Existing `/rename <name>` and bare `/rename` (kebab-case via existing path) are unchanged, except the kebab path now prefers fast model when available and runs its output through `stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the sentence-case path). New shared util: `packages/core/src/utils/terminalSafe.ts` — `stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI (\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise execute on every SessionPicker render; both sentence-case and kebab paths now route titles through the helper before they reach the JSONL or the UI. Tail-read extractor: `extractLastJsonStringFields(text, primaryKey, otherKeys, lineContains)` reads multiple fields from the same matching line in a single pass. Two separate tail scans could return a mismatched pair (primary from a newer record, secondary from an older one with only the primary set); the new helper guarantees the pair is atomic. Validates a proper closing quote on the primary value so a crash-truncated trailing record can't win the latest-match race. `readLastJsonStringFieldsSync` is its file-reading wrapper — same tail-window fast path and full-file fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB` cap so a corrupt multi-GB session file can't freeze the picker. Session reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows where the constant isn't exposed) — defense in depth against a symlink planted in `~/.qwen/projects/<proj>/chats/`. Character handling: `flattenToTail` on the LLM prompt drops a dangling low surrogate after `slice(-1000)` — otherwise a CJK supplementary char or emoji cut mid-pair produces invalid UTF-16 that some providers 400. `sanitizeTitle` applies the same surrogate scrub after max-length trim, and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char strip. `lineContains` in the title reader is tightened from the loose substring `'custom_title'` to `'"subtype":"custom_title"'` so user text containing the literal `custom_title` can't shadow a real record. Tests: 46 new unit tests across - `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets. - `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix, in-flight guard, abort propagation on finalize, manual/auto/legacy resume symmetry, cross-process race, env opt-out, retry-after- transient. - `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle boundary, truncated trailing record, lineContains, multi-field atom. - `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel, unknown-flag hint, positional rejection, manual/SessionService fallbacks. * docs(session): design doc for auto session titles Matches the session-recap design doc shape (Overview / Triggers / Architecture / Prompt Design / History Filtering / Persistence / Concurrency / Configuration / Observability / Out of Scope) and adds a Security Hardening section unique to the title path — titles render directly in the picker and persist in user-readable JSONL, so LLM-returned control sequences are an attack surface the recap path doesn't have. Captures decisions a code-only reader has to reverse-engineer: - Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop). - Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call). - Why the auto trigger does NOT fall back to the main model but session-recap does (auto-title fires on every turn; silently charging main-model tokens is a bill surprise). - Why `titleSource: undefined` stays unwritten on legacy records (no rewrite risks silently reclassifying user intent). - Why the cross-process re-read sits between the LLM await and the append (manual wins at both in-process and on-disk layers). - Why `finalize()`'s abort tolerates a controller swap (in-flight identity check). - Why JSON-schema function calling instead of tag extraction (avoid reasoning preamble bleed; cross-provider reliability). Placed at docs/design/session-title/ alongside session-recap, compact-mode, fork-subagent, and other per-feature design docs. No sidebar index update required — the design folder is unindexed. * test(rename): pin model choice in bare /rename kebab path Addresses reviewer feedback: the bare `/rename` model selection (`config.getFastModel() ?? config.getModel()`) had no test pinning it either way. Previous tests mocked `getHistory: []`, which exits the function before the model is ever chosen, so a silent regression to either direction (always-main or always-fast) would pass CI. Two explicit cases now: - fastModel set → `generateContent` called with `model: 'qwen-turbo'`. - fastModel unset → `generateContent` called with `model: 'main-model'`. The tests intentionally mock a non-empty history so the kebab path reaches the generateContent call site instead of bailing on empty input.
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session renaming/deletion + custom-title support. Skips the auto-title-via-LLM piece (depends on un-ported gateway shape) and the vscode-ide-companion files (deleted in our fork). What's in: - /rename: prompt for a custom session title; persisted via ChatRecordingService.recordCustomTitle and surfaced in the picker. - /delete: opens a SessionPicker that calls SessionService.removeSession on selection. - SessionListItem.customTitle field + readSessionTitleFromFile tail scanner on session file load. - SessionService.renameSession / getSessionTitle / findSessionsByTitle. - ACP extMethod handlers for renameSession + deleteSession. - SessionStart restores session-name tag from the persisted custom title via useInitializationEffects. - --resume now accepts UUID or title (validation moved to runtime). Conflict resolution notes: - Kept HEAD's bg-agent useEffect block; the upstream init useEffect was already extracted into useInitializationEffects, so the customTitle restore goes there with an optional setSessionName arg. - Kept HEAD's rewind dialog; added the delete dialog as a sibling. - Kept HEAD's voice/recap state; added sessionName/setSessionName to UIState. Dropped upstream's streamingResponseLengthRef + isReceivingContent (token-display PR QwenLM#3329, un-ported). - Dropped upstream MemoryDialog import (auto-memory un-ported); kept the i18n t import for the Delete dialog title. Tests: 29 new tests pass (rename/delete commands, customTitle recording, sessionService rename/find). Resume tests still pass. Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a generateSessionTitle path through ContentGenerator that needs adaptation to our gateway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session renaming/deletion + custom-title support. Skips the auto-title-via-LLM piece (depends on un-ported gateway shape) and the vscode-ide-companion files (deleted in our fork). What's in: - /rename: prompt for a custom session title; persisted via ChatRecordingService.recordCustomTitle and surfaced in the picker. - /delete: opens a SessionPicker that calls SessionService.removeSession on selection. - SessionListItem.customTitle field + readSessionTitleFromFile tail scanner on session file load. - SessionService.renameSession / getSessionTitle / findSessionsByTitle. - ACP extMethod handlers for renameSession + deleteSession. - SessionStart restores session-name tag from the persisted custom title via useInitializationEffects. - --resume now accepts UUID or title (validation moved to runtime). Conflict resolution notes: - Kept HEAD's bg-agent useEffect block; the upstream init useEffect was already extracted into useInitializationEffects, so the customTitle restore goes there with an optional setSessionName arg. - Kept HEAD's rewind dialog; added the delete dialog as a sibling. - Kept HEAD's voice/recap state; added sessionName/setSessionName to UIState. Dropped upstream's streamingResponseLengthRef + isReceivingContent (token-display PR QwenLM#3329, un-ported). - Dropped upstream MemoryDialog import (auto-memory un-ported); kept the i18n t import for the Delete dialog title. Tests: 29 new tests pass (rename/delete commands, customTitle recording, sessionService rename/find). Resume tests still pass. Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a generateSessionTitle path through ContentGenerator that needs adaptation to our gateway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…) (#218) * feat(session): port /rename and /delete with custom titles (QwenLM#3093) Cherry-picks upstream qwen-code PR QwenLM#3093, which adds session renaming/deletion + custom-title support. Skips the auto-title-via-LLM piece (depends on un-ported gateway shape) and the vscode-ide-companion files (deleted in our fork). What's in: - /rename: prompt for a custom session title; persisted via ChatRecordingService.recordCustomTitle and surfaced in the picker. - /delete: opens a SessionPicker that calls SessionService.removeSession on selection. - SessionListItem.customTitle field + readSessionTitleFromFile tail scanner on session file load. - SessionService.renameSession / getSessionTitle / findSessionsByTitle. - ACP extMethod handlers for renameSession + deleteSession. - SessionStart restores session-name tag from the persisted custom title via useInitializationEffects. - --resume now accepts UUID or title (validation moved to runtime). Conflict resolution notes: - Kept HEAD's bg-agent useEffect block; the upstream init useEffect was already extracted into useInitializationEffects, so the customTitle restore goes there with an optional setSessionName arg. - Kept HEAD's rewind dialog; added the delete dialog as a sibling. - Kept HEAD's voice/recap state; added sessionName/setSessionName to UIState. Dropped upstream's streamingResponseLengthRef + isReceivingContent (token-display PR QwenLM#3329, un-ported). - Dropped upstream MemoryDialog import (auto-memory un-ported); kept the i18n t import for the Delete dialog title. Tests: 29 new tests pass (rename/delete commands, customTitle recording, sessionService rename/find). Resume tests still pass. Follow-up: auto-title generation (QwenLM#3540) deferred — it depends on a generateSessionTitle path through ContentGenerator that needs adaptation to our gateway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: nudge PR conflict recomputation --------- Co-authored-by: Automaker <automaker@localhost> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
…ion (QwenLM#3093) * feat(session): add rename, delete, and auto-title generation for sessions - Add /rename command with LLM auto-title generation when no args provided - Add /delete command to remove sessions from the session picker - Display session name tag embedded in input prompt top border - Restore session name on /resume and --resume <title> CLI flag - Support rename and delete via ACP extMethod for VSCode extension - Add rename/delete UI to WebUI SessionSelector with two-click delete confirmation - Fix parentUuid chain: custom_title records now correctly reference the previous record's UUID, preventing session history from appearing empty after rename - Add SESSION_FILE_PATTERN validation to all SessionService methods that construct file paths from sessionId (defense-in-depth against path traversal) - Fix fd leak in readCustomTitleFromFile with try/finally - Fix --resume <title> exit code (exit 1 when no match found) - Add project ownership checks to VSCode qwenSessionReader delete/rename Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(session): fix broken imports and missing mocks from rename/auto-title feature - Fix renameCommand.ts import path to use barrel export instead of deep path - Add setSessionName to mock CommandContext - Add getSessionTitle to SessionService mock in useResumeCommand tests - Update renameCommand tests for auto-generate title behavior - Update InputPrompt snapshots Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(session): add head+tail dual-read, string-level extraction, and finalize mechanism - Add sessionStorageUtils with extractLastJsonStringField() for fast string-level JSON field extraction without full parse - Add readHeadAndTailSync() to read first and last 64KB of session files - Replace readCustomTitleFromFile() with readSessionTitleFromFile() using head+tail dual-read (tail customTitle > head customTitle) - Add finalize() to ChatRecordingService as single entry point for re-appending session metadata on any session departure - Call finalize() on resume, session switch, and shutdown - Export sessionStorageUtils from core package Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): show filtered picker when /resume <title> matches multiple sessions Previously, multiple title matches opened the full session picker, forcing the user to re-find their session. Now the matched sessions are passed through as initialSessions to the picker, skipping the full listSessions() load and showing only the relevant results. Also clears sessionName on /clear so new sessions don't carry stale title tags from the previous session. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ui): use stringWidth for CJK-safe border alignment in input prompt topRightLabel.length counts UTF-16 code units, not terminal columns. CJK characters take 2 columns but .length returns 1, causing the border line to overflow. Use string-width for correct display width. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): address remaining PR QwenLM#3093 review feedback - Add SESSION_TITLE_MAX_LENGTH shared constant in core, replace hardcoded 200 in CLI/ACP/VSCode/WebUI - Add title length validation to ACP renameSession endpoint - Make recordCustomTitle return boolean; renameCommand checks it before updating UI to prevent silent data loss - Add gitBranch to VSCode rename record for consistency with CLI - Remove misleading "enforce kebab-case" comment - Remove duplicate JSDoc on topRightLabel Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ui): add animated dots to session name generation loading indicator The static "Generating session name…" text gave no visual feedback that the operation was in progress. Cycle through ".", "..", "..." every 500ms so users can tell the LLM call is still running. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * feat(cli): add /tag as alias for /rename command Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(vscode): add loading overlay when switching to historical conversations Adds isSwitchingSession state and sessionLoadComplete message to show a loading transition while session history is being rehydrated via ACP. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(vscode): add 15s timeout fallback for session switching loading state Prevents loading overlay from getting stuck indefinitely if sessionLoadComplete message is never received. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(core): fix extractLastJsonStringField offset tracking and add lineContains filter 1. Track global character offset across both pattern variants so the truly last match wins (previously the second pattern scan could overwrite a later match from the first pattern). 2. Add optional lineContains parameter to scope matches to lines containing a marker (e.g. "custom_title"), preventing false matches from user content that happens to include a "customTitle" field. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * chore(cli): add i18n import to DialogManager Co-Authored-By: Qwen-Coder <noreply@qwen.ai> * fix(vscode): align currentConversationId with webview on fallback restore When session/load falls back to creating a fresh ACP session, backend was tracking the new ACP id while the webview still viewed the archived sessionId. That desync caused delete/rename/title-update to target the wrong session during the fallback window, and prevented the post-first- message sync path from firing because the two ids were pre-aligned. Keep currentConversationId pointing at the archived sessionId until the existing stream-end sync flips both sides to the live ACP id on the first user message. Matches the pattern already used by the offline branch. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(core): exhaustive scan in findSessionsByTitle to avoid mtime-boundary misses listSessions() paginates with an mtime-only cursor and strict `<` filter. When several session files share the same mtime across a page boundary, the next page's filter drops them, so --resume <title> could silently miss valid matches. Scan all session files directly for title lookup, with filename as a stable tie-breaker. Also check the (cheap) custom title before the full hydration pass (first-record read, project filter, message count, prompt extraction) so non-matching sessions skip the extra I/O. listSessions() itself is left alone: its cursor crosses ACP/webview package boundaries as a number and this edge case only affects UI display order, not data loss. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(acp): plumb listSessions page size through _meta VSCode companion passes `size` to acpConnection.listSessions, but the ACP spec's ListSessionsRequest schema has no `size` field, so the SDK's zod validator strips it before the agent handler sees it. The agent then only forwarded `cursor` to SessionService.listSessions, silently ignoring the caller's page-size intent. Carry page size through `_meta.size` on both sides, matching the pattern already used for other Qwen Code ACP extensions (e.g. the filesystem service's `_meta.bom` / `_meta.encoding`). `_meta` is typed as an open record in the ACP schema, so extra keys survive validation. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(webui): avoid unintended rename when canceling with Escape The rename input auto-submits on blur, and pressing Escape also triggers blur (via setRenamingSessionId(null) unmounting the input). Because state updates are async, the blur handler's handleRenameSubmit could still read the pre-Escape renameValue from its closure and call onRenameSession, turning a cancel into an accidental rename. Track cancellation via an isCancelingRenameRef flag: set it in the Escape branch, and have onBlur short-circuit when the flag is true, then reset it. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(vscode-ide-companion): clear switch timeout on unmount The 15s session-switch fallback timer was only cleared on the next call to setIsSwitchingSession. If the webview is torn down mid-switch, the timer stays alive and later fires setIsSwitchingSessionRaw(false) on an unmounted hook. Add a useEffect cleanup to clear any pending timer on unmount. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(cli): break handleResume/slashCommandActions circular dep slashCommandActions (useMemo) depends on handleResume, but handleResume was declared after useSlashCommandProcessor so it could call setAwayRecapItem(null). useSlashCommandProcessor itself consumes slashCommandActions, closing a three-way cycle that tsc catches as TS2448 "used before declaration" once QwenLM#3478's AppContainer changes land in main and get auto-merged into open PRs. Move handleResume above slashCommandActions and route the recap clear through a ref that a later useEffect syncs with setAwayRecapItem. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(core): scan full file when title is not in tail window Replace the head+tail dual-read with readLastJsonStringFieldSync: scan the tail first and return on hit, otherwise stream the whole file and return the last match. Closes the blind spot where a custom_title record landing between the head and tail windows would be missed on large session files. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <noreply@qwen.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen-Coder <noreply@qwen.ai> Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
…QwenLM#3540) * feat(session): auto-title sessions via fast model, add /rename --auto The /rename work in QwenLM#3093 generates kebab-case titles only when the user explicitly runs `/rename` with no args; until they do, the session picker shows the first user prompt (often truncated or misleading). This change adds a sentence-case auto-title that fires once per session after the first assistant turn, using the configured fast model. New service: `packages/core/src/services/sessionTitle.ts` — `tryGenerateSessionTitle(config, signal)` returns a discriminated outcome (`{ok: true, title, modelUsed}` | `{ok: false, reason}`) so callers can either handle failures generically or map reasons to actionable messages. Prompt shape: 3-7 words, sentence case, good/bad examples including a CJK row, JSON schema enforced via `baseLlmClient.generateJson`. `maxAttempts: 1` — titles are cosmetic metadata and shouldn't fight rate limits. Trigger point: `ChatRecordingService.maybeTriggerAutoTitle` runs after `recordAssistantTurn`. Fire-and-forget promise, guarded by: - `currentCustomTitle` — don't overwrite any existing title. - `autoTitleController` doubles as in-flight flag; a second turn while the first is still pending is a no-op. - `autoTitleAttempts` cap of 3 — the first assistant turn may be a pure tool-call with no user-visible text; retry for a handful of turns until a title lands. Cap bounds total waste. - `!config.isInteractive()` — headless CLI (`qwen -p`, CI) never auto- titles; spending fast-model tokens on a one-shot session is waste. - `autoTitleDisabledByEnv()` — `QWEN_DISABLE_AUTO_TITLE=1` opt-out. - `config.getFastModel()` falsy — skip entirely rather than falling back to the main model; auto-titling on main-model tokens is too expensive to be silent. Persistence: `CustomTitleRecordPayload` grows a `titleSource: 'auto' | 'manual'` field. Absent on pre-change records (treated as `undefined` → manual, safe default so a user's pre-upgrade `/rename` is never silently reclassified). `SessionPicker` renders `titleSource === 'auto'` titles in dim (secondary) color; manual stays full contrast. On resume, the persisted source is rehydrated into `currentTitleSource` — without this, finalize's re-append would rewrite an auto title as manual on every resume cycle. Cross-process manual-rename guard: when two CLI tabs target the same JSONL, in-memory state can diverge. Before writing an auto record, the IIFE re-reads the file via `sessionService.getSessionTitleInfo`. If a `/rename` from another process landed as manual, bail and sync local state — never clobber a deliberately-chosen manual title with a model guess. Cost is one 64KB tail read per successful generation. `finalize()` aborts the in-flight controller before re-appending the title record. Session switch / shutdown doesn't have to wait on a slow fast-model call. New user-facing command: `/rename --auto` regenerates via the same generator — explicit user trigger, overwrites whatever's there (manual or auto) because the user asked. Errors route through `autoFailureMessage(reason)` so `empty_history`, `model_error`, `aborted`, etc. each get actionable guidance rather than a generic "could not generate". `/rename -- --literal-name` is the sentinel for titles that start with `--`; unknown `--flag` tokens error with a hint pointing at the sentinel. Existing `/rename <name>` and bare `/rename` (kebab-case via existing path) are unchanged, except the kebab path now prefers fast model when available and runs its output through `stripTerminalControlSequences` (same ANSI/OSC-8 hardening as the sentence-case path). New shared util: `packages/core/src/utils/terminalSafe.ts` — `stripTerminalControlSequences(s)` strips OSC (\x1b]...\x07|\x1b\\), CSI (\x1b[...[a-zA-Z]), SS2/SS3 leaders, and C0/C1/DEL as a backstop. A model-returned `\x1b[2J` or OSC-8 hyperlink escape would otherwise execute on every SessionPicker render; both sentence-case and kebab paths now route titles through the helper before they reach the JSONL or the UI. Tail-read extractor: `extractLastJsonStringFields(text, primaryKey, otherKeys, lineContains)` reads multiple fields from the same matching line in a single pass. Two separate tail scans could return a mismatched pair (primary from a newer record, secondary from an older one with only the primary set); the new helper guarantees the pair is atomic. Validates a proper closing quote on the primary value so a crash-truncated trailing record can't win the latest-match race. `readLastJsonStringFieldsSync` is its file-reading wrapper — same tail-window fast path and full-file fallback as the single-field version, plus a `MAX_FULL_SCAN_BYTES = 64MB` cap so a corrupt multi-GB session file can't freeze the picker. Session reads now open with `O_NOFOLLOW` (falls back to plain RDONLY on Windows where the constant isn't exposed) — defense in depth against a symlink planted in `~/.qwen/projects/<proj>/chats/`. Character handling: `flattenToTail` on the LLM prompt drops a dangling low surrogate after `slice(-1000)` — otherwise a CJK supplementary char or emoji cut mid-pair produces invalid UTF-16 that some providers 400. `sanitizeTitle` applies the same surrogate scrub after max-length trim, and strips paired CJK brackets (`「」 『』 【】 〈〉 《》`) as whole units so a `【Draft】 Fix login` doesn't leave a dangling `】` after leading-char strip. `lineContains` in the title reader is tightened from the loose substring `'custom_title'` to `'"subtype":"custom_title"'` so user text containing the literal `custom_title` can't shadow a real record. Tests: 46 new unit tests across - `sessionTitle.test.ts` (22): success/all-failure-reasons, tool-call filter, tail-slice, surrogate scrub, ANSI/OSC-8 strip, CJK brackets. - `chatRecordingService.autoTitle.test.ts` (15): trigger/skip matrix, in-flight guard, abort propagation on finalize, manual/auto/legacy resume symmetry, cross-process race, env opt-out, retry-after- transient. - `sessionStorageUtils.test.ts` (13): single-pass extractor, straddle boundary, truncated trailing record, lineContains, multi-field atom. - `renameCommand.test.ts` (8): `--auto` success, all reasons, sentinel, unknown-flag hint, positional rejection, manual/SessionService fallbacks. * docs(session): design doc for auto session titles Matches the session-recap design doc shape (Overview / Triggers / Architecture / Prompt Design / History Filtering / Persistence / Concurrency / Configuration / Observability / Out of Scope) and adds a Security Hardening section unique to the title path — titles render directly in the picker and persist in user-readable JSONL, so LLM-returned control sequences are an attack surface the recap path doesn't have. Captures decisions a code-only reader has to reverse-engineer: - Why `maxAttempts: 1` (best-effort cosmetic metadata; no retry loop). - Why `autoTitleAttempts` cap is 3 (first turn can be pure tool-call). - Why the auto trigger does NOT fall back to the main model but session-recap does (auto-title fires on every turn; silently charging main-model tokens is a bill surprise). - Why `titleSource: undefined` stays unwritten on legacy records (no rewrite risks silently reclassifying user intent). - Why the cross-process re-read sits between the LLM await and the append (manual wins at both in-process and on-disk layers). - Why `finalize()`'s abort tolerates a controller swap (in-flight identity check). - Why JSON-schema function calling instead of tag extraction (avoid reasoning preamble bleed; cross-provider reliability). Placed at docs/design/session-title/ alongside session-recap, compact-mode, fork-subagent, and other per-feature design docs. No sidebar index update required — the design folder is unindexed. * test(rename): pin model choice in bare /rename kebab path Addresses reviewer feedback: the bare `/rename` model selection (`config.getFastModel() ?? config.getModel()`) had no test pinning it either way. Previous tests mocked `getHistory: []`, which exits the function before the model is ever chosen, so a silent regression to either direction (always-main or always-fast) would pass CI. Two explicit cases now: - fastModel set → `generateContent` called with `model: 'qwen-turbo'`. - fastModel unset → `generateContent` called with `model: 'main-model'`. The tests intentionally mock a non-empty history so the kebab path reaches the generateContent call site instead of bailing on empty input.
TLDR
Add session rename, delete, and auto-title generation across CLI, VSCode extension, and WebUI. Users can now
/renamesessions (with optional LLM-generated titles),/deletesessions, and resume sessions by custom title via--resume <title>. The session name is displayed as a tag in the CLI input prompt and persists across resume.Screenshots / Video Demo
Dive Deeper
Architecture
Custom titles are stored as append-only system records in the session JSONL file:
{"uuid":"...","parentUuid":"<previous-record-uuid>","type":"system","subtype":"custom_title","systemPayload":{"customTitle":"my-feature"}}Key design decisions:
parentUuidmust correctly chain to the previous record — without this,reconstructHistory()(which walks from the tail record upward) would sever the chain and the session would appear empty on next loadreadCustomTitleFromFile), not full file scan/renamecommand usesChatRecordingService.recordCustomTitle()which inherits correctparentUuidfromlastRecordUuid. The ACP/VSCode path usesSessionService.renameSession()which explicitly reads the last record's UUID before writingSecurity hardening
SessionServicemethods that construct file paths fromsessionIdnow validate againstSESSION_FILE_PATTERN(defense-in-depth against path traversal)qwenSessionReaderdelete/rename verify project ownership viaprojectHashreadCustomTitleFromFileusestry/finallyto prevent fd leaksAuto-title generation
When
/renameis called without arguments, it extracts the last ~1000 chars of conversation history and sends a single request to the current model asking for a kebab-case title (e.g.,fix-login-bug). A pending indicator ("Generating session name…") is shown during the request.Resume by title
--resume <title>performs a case-insensitive exact match against custom titles. If multiple sessions match, the interactive picker is shown. If no match and the input isn't a valid UUID, exits with code 1.Reviewer Test Plan
CLI
/rename— should auto-generate a kebab-case title and show it as a tag in the input border/rename my-custom-name— should set the title and show tag/resume— the renamed session should show its custom title in the picker/delete— select a non-current session, confirm deletionqwen --resume my-custom-name— should resume by titleqwen --resume nonexistent— should exit with code 1VSCode Extension
WebUI
maxLength={200}Edge cases
/renamewith empty input (just Enter) — should trigger auto-generate, not error/renamewith very long name (>200 chars) — should show errorTesting Matrix
Linked issues / bugs
resolves: #2619 #2999 #3032 #3078 #3234