From bcf4f280875e5bef1b8cff0cbb96aa379e87ef6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Fri, 10 Apr 2026 19:49:01 +0800 Subject: [PATCH 01/13] feat: add /chat command for saving, listing, resuming, and deleting named sessions --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/commands/chatCommand.ts | 224 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/services/chatIndex.test.ts | 155 ++++++++++++ packages/core/src/services/chatIndex.ts | 100 ++++++++ 5 files changed, 482 insertions(+) create mode 100644 packages/cli/src/ui/commands/chatCommand.ts create mode 100644 packages/core/src/services/chatIndex.test.ts create mode 100644 packages/core/src/services/chatIndex.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 9258298394e..423b9e5b391 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -14,6 +14,7 @@ import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { btwCommand } from '../ui/commands/btwCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; +import { chatCommand } from '../ui/commands/chatCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { contextCommand } from '../ui/commands/contextCommand.js'; @@ -88,6 +89,7 @@ export class BuiltinCommandLoader implements ICommandLoader { authCommand, btwCommand, bugCommand, + chatCommand, clearCommand, compressCommand, contextCommand, diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts new file mode 100644 index 00000000000..019f143bc54 --- /dev/null +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { MessageType } from '../types.js'; +import { t } from '../../i18n/index.js'; +import { + saveSessionToIndex, + deleteSessionFromIndex, + getSessionIdByName, + listNamedSessions, +} from '@qwen-code/qwen-code-core'; + +export const chatCommand: SlashCommand = { + name: 'chat', + get description() { + return t('Save, list, resume, and delete named chat sessions.'); + }, + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'save', + get description() { + return t('Save the current session with a name.'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const name = args.trim(); + + if (!name) { + return { + type: 'message', + messageType: 'error', + content: t('Please provide a name. Usage: /chat save '), + }; + } + + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const sessionId = config.getSessionId(); + + try { + await saveSessionToIndex(name, sessionId); + return { + type: 'message', + messageType: 'info', + content: t('Session saved as "{{name}}" (ID: {{sessionId}})', { + name, + sessionId, + }), + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to save session: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + }, + }, + { + name: 'list', + get description() { + return t('List all saved session names.'); + }, + kind: CommandKind.BUILT_IN, + action: async (): Promise => { + try { + const sessions = await listNamedSessions(); + const names = Object.keys(sessions); + + if (names.length === 0) { + return { + type: 'message', + messageType: 'info', + content: t('No saved sessions found.'), + }; + } + + const content = names + .map((name) => { + const shortId = sessions[name].substring(0, 8); + return `• ${name} (ID: ${shortId}...)`; + }) + .join('\n'); + + return { + type: 'message', + messageType: 'info', + content: t('Saved sessions:\n\n{{sessions}}', { sessions: content }), + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to list sessions: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + }, + }, + { + name: 'resume', + get description() { + return t('Resume a session by name.'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const name = args.trim(); + + if (!name) { + return { + type: 'message', + messageType: 'error', + content: t('Please provide a name. Usage: /chat resume '), + }; + } + + try { + const sessionId = await getSessionIdByName(name); + + if (!sessionId) { + return { + type: 'message', + messageType: 'error', + content: t('Session "{{name}}" not found.', { name }), + }; + } + + // 返回 dialog 类型,触发恢复会话对话框 + // 但实际上我们需要直接恢复,而不是打开选择器 + // 这里返回 sessionId,由外部处理 + return { + type: 'message', + messageType: 'info', + content: t( + 'Found session "{{name}}" with ID: {{sessionId}}\nUse /resume to select it, or restart with: qwen --session-id {{sessionId}}', + { name, sessionId }, + ), + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to resume session: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + }, + }, + { + name: 'delete', + get description() { + return t('Delete a saved session by name.'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const name = args.trim(); + + if (!name) { + return { + type: 'message', + messageType: 'error', + content: t('Please provide a name. Usage: /chat delete '), + }; + } + + try { + const deleted = await deleteSessionFromIndex(name); + + if (!deleted) { + return { + type: 'message', + messageType: 'error', + content: t('Session "{{name}}" not found.', { name }), + }; + } + + return { + type: 'message', + messageType: 'info', + content: t('Session "{{name}}" deleted.', { name }), + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to delete session: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + }, + }, + ], +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2708890b63d..9a5dc5bb3b4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -106,6 +106,7 @@ export * from './tools/cron-delete.js'; // ============================================================================ export * from './services/chatRecordingService.js'; +export * from './services/chatIndex.js'; export * from './services/cronScheduler.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileSystemService.js'; diff --git a/packages/core/src/services/chatIndex.test.ts b/packages/core/src/services/chatIndex.test.ts new file mode 100644 index 00000000000..70fcdb71857 --- /dev/null +++ b/packages/core/src/services/chatIndex.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs/promises'; + +// Mock os.homedir to avoid writing to real home directory +// Must use vi.hoisted because vi.mock is hoisted to the top +const { mockHomeDir } = vi.hoisted(() => ({ + mockHomeDir: require('path').join(process.env.TEMP || '/tmp', 'mock-home-test'), +})); + +vi.mock('node:os', () => ({ + default: { + homedir: () => mockHomeDir, + }, +})); + +import path from 'node:path'; +import { + saveSessionToIndex, + deleteSessionFromIndex, + getSessionIdByName, + listNamedSessions, + readChatIndex, +} from './chatIndex.js'; + +describe('chatIndex', () => { + const qwenDir = path.join(mockHomeDir, '.qwen'); + const indexPath = path.join(qwenDir, 'chat-index.json'); + + beforeEach(async () => { + // Clean up mock directory + try { + await fs.rm(mockHomeDir, { recursive: true, force: true }); + } catch { + // Ignore + } + }); + + afterEach(async () => { + // Clean up + try { + await fs.rm(mockHomeDir, { recursive: true, force: true }); + } catch { + // Ignore + } + }); + + describe('saveSessionToIndex', () => { + it('should save a session to the index', async () => { + const name = 'test-session'; + const sessionId = 'session-123'; + + await saveSessionToIndex(name, sessionId); + + const index = await readChatIndex(); + expect(index[name]).toBe(sessionId); + }); + + it('should overwrite an existing session with the same name', async () => { + const name = 'test-session'; + const sessionId1 = 'session-123'; + const sessionId2 = 'session-456'; + + await saveSessionToIndex(name, sessionId1); + await saveSessionToIndex(name, sessionId2); + + const index = await readChatIndex(); + expect(index[name]).toBe(sessionId2); + }); + + it('should create the .qwen directory if it does not exist', async () => { + const name = 'test-session'; + const sessionId = 'session-123'; + + await saveSessionToIndex(name, sessionId); + + const stat = await fs.stat(qwenDir); + expect(stat.isDirectory()).toBe(true); + }); + }); + + describe('deleteSessionFromIndex', () => { + it('should delete a session from the index', async () => { + const name = 'test-session'; + const sessionId = 'session-123'; + + await saveSessionToIndex(name, sessionId); + const deleted = await deleteSessionFromIndex(name); + + expect(deleted).toBe(true); + const index = await readChatIndex(); + expect(index[name]).toBeUndefined(); + }); + + it('should return false if session does not exist', async () => { + const deleted = await deleteSessionFromIndex('nonexistent'); + expect(deleted).toBe(false); + }); + }); + + describe('getSessionIdByName', () => { + it('should return the session ID if it exists', async () => { + const name = 'test-session'; + const sessionId = 'session-123'; + + await saveSessionToIndex(name, sessionId); + const foundId = await getSessionIdByName(name); + + expect(foundId).toBe(sessionId); + }); + + it('should return undefined if session does not exist', async () => { + const foundId = await getSessionIdByName('nonexistent'); + expect(foundId).toBeUndefined(); + }); + }); + + describe('listNamedSessions', () => { + it('should return all named sessions', async () => { + await saveSessionToIndex('session1', 'id-1'); + await saveSessionToIndex('session2', 'id-2'); + + const sessions = await listNamedSessions(); + + expect(Object.keys(sessions)).toHaveLength(2); + expect(sessions['session1']).toBe('id-1'); + expect(sessions['session2']).toBe('id-2'); + }); + + it('should return empty object when no sessions exist', async () => { + const sessions = await listNamedSessions(); + expect(Object.keys(sessions)).toHaveLength(0); + }); + }); + + describe('readChatIndex', () => { + it('should return empty object when file does not exist', async () => { + const index = await readChatIndex(); + expect(index).toEqual({}); + }); + + it('should handle corrupted index files gracefully', async () => { + await fs.mkdir(qwenDir, { recursive: true }); + await fs.writeFile(indexPath, 'invalid json', 'utf-8'); + + const index = await readChatIndex(); + expect(index).toEqual({}); + }); + }); +}); diff --git a/packages/core/src/services/chatIndex.ts b/packages/core/src/services/chatIndex.ts new file mode 100644 index 00000000000..c3c204989a3 --- /dev/null +++ b/packages/core/src/services/chatIndex.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { QWEN_DIR } from '../config/storage.js'; + +/** + * 会话索引数据结构 + * 存储在 ~/.qwen/chat-index.json 中 + */ +export interface ChatIndex { + /** name -> sessionId 的映射 */ + [name: string]: string; +} + +/** + * 获取索引文件路径 + */ +function getIndexPath(): string { + return path.join(os.homedir(), QWEN_DIR, 'chat-index.json'); +} + +/** + * 确保 ~/.qwen 目录存在 + */ +async function ensureQwenDir(): Promise { + const qwenDir = path.join(os.homedir(), QWEN_DIR); + await fs.mkdir(qwenDir, { recursive: true }); +} + +/** + * 读取索引文件 + * @returns 索引对象,如果文件不存在则返回空对象 + */ +export async function readChatIndex(): Promise { + try { + const content = await fs.readFile(getIndexPath(), 'utf-8'); + return JSON.parse(content) as ChatIndex; + } catch (error) { + // 文件不存在或解析错误,返回空索引 + return {}; + } +} + +/** + * 保存会话到索引 + * @param name 会话名称 + * @param sessionId 会话 ID + */ +export async function saveSessionToIndex( + name: string, + sessionId: string, +): Promise { + await ensureQwenDir(); + + const index = await readChatIndex(); + index[name] = sessionId; + + await fs.writeFile(getIndexPath(), JSON.stringify(index, null, 2), 'utf-8'); +} + +/** + * 从索引中删除会话 + * @param name 会话名称 + * @returns 是否删除成功 + */ +export async function deleteSessionFromIndex(name: string): Promise { + const index = await readChatIndex(); + + if (!(name in index)) { + return false; + } + + delete index[name]; + await fs.writeFile(getIndexPath(), JSON.stringify(index, null, 2), 'utf-8'); + return true; +} + +/** + * 根据名称获取会话 ID + * @param name 会话名称 + * @returns 会话 ID,如果不存在则返回 undefined + */ +export async function getSessionIdByName(name: string): Promise { + const index = await readChatIndex(); + return index[name]; +} + +/** + * 列出所有已命名的会话 + * @returns 名称到 sessionId 的映射 + */ +export async function listNamedSessions(): Promise { + return await readChatIndex(); +} From d5f94b992c9695c8e96a08c449c2fd5d03258426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Fri, 10 Apr 2026 21:07:33 +0800 Subject: [PATCH 02/13] fix: resolve TypeScript errors and add comprehensive tests for /chat command --- PR_GUIDE.md | 146 ++++++++++++++ package-lock.json | 1 - .../cli/src/ui/commands/chatCommand.test.ts | 189 ++++++++++++++++++ packages/cli/src/ui/commands/chatCommand.ts | 1 - packages/core/src/services/chatIndex.test.ts | 2 +- test-chat-parse.mjs | 42 ++++ 6 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 PR_GUIDE.md create mode 100644 packages/cli/src/ui/commands/chatCommand.test.ts create mode 100644 test-chat-parse.mjs diff --git a/PR_GUIDE.md b/PR_GUIDE.md new file mode 100644 index 00000000000..85720530831 --- /dev/null +++ b/PR_GUIDE.md @@ -0,0 +1,146 @@ +# PR 创建指南 + +## 步骤 1: Fork 仓库 + +在浏览器中打开 https://github.com/QwenLM/qwen-code,点击右上角的 "Fork" 按钮创建你自己的 fork。 + +## 步骤 2: 添加你的 fork 为远程仓库 + +在你的 fork 创建完成后,运行: + +```bash +cd D:\code\qwen-code +git remote add fork https://github.com/lnxsun/qwen-code.git +git push -u fork feat/chat-session-command +``` + +## 步骤 3: 创建 PR + +使用以下链接创建 PR(替换为你的 fork URL): + +``` +https://github.com/QwenLM/qwen-code/compare/main...lnxsun:qwen-code:feat/chat-session-command?expand=1 +``` + +或者在 GitHub 页面上: +1. 进入 https://github.com/QwenLM/qwen-code +2. 点击 "Pull requests" 标签 +3. 点击 "New pull request" +4. 选择你的分支 `feat/chat-session-command` +5. 使用下面的 PR 描述 + +--- + +## PR 标题 + +``` +feat: add /chat command for saving, listing, resuming, and deleting named sessions +``` + +## PR 描述 + +```markdown +## Summary + +This PR implements a new `/chat` slash command for managing named chat sessions, inspired by the iflow CLI's session management features (related to #3025). + +## Features + +The `/chat` command provides four subcommands: + +- **`/chat save `** - Save the current session with a custom name +- **`/chat list`** - List all saved session names with their shortened IDs +- **`/chat resume `** - Look up a session ID by name for easy restoration +- **`/chat delete `** - Remove a saved session from the index + +## Implementation Details + +### New Files + +1. **`packages/core/src/services/chatIndex.ts`** + - Session index management module + - Stores name-to-sessionID mappings in `~/.qwen/chat-index.json` + - Provides CRUD operations for the session index + +2. **`packages/cli/src/ui/commands/chatCommand.ts`** + - Implementation of the `/chat` slash command with all subcommands + - Follows existing command patterns (similar to `/memory`, `/btw`) + +3. **`packages/core/src/services/chatIndex.test.ts`** + - Comprehensive unit tests for the chat index module + - 11 tests all passing + +### Modified Files + +- `packages/core/src/index.ts` - Export the new chatIndex module +- `packages/cli/src/services/BuiltinCommandLoader.ts` - Register the chatCommand + +## Usage Examples + +```bash +# Save current session as "my-feature-work" +/chat save my-feature-work + +# List all saved sessions +/chat list +# Output: +# Saved sessions: +# • my-feature-work (ID: abc12345...) +# • debugging-session (ID: def67890...) + +# Find session ID for restoration +/chat resume my-feature-work +# Output: Found session "my-feature-work" with ID: abc12345-... +# Use /resume to select it, or restart with: qwen --session-id abc12345-... + +# Delete a saved session +/chat delete my-feature-work +``` + +## Design Decisions + +1. **Separate index file**: Uses `~/.qwen/chat-index.json` instead of modifying the existing auto-save mechanism in `~/.qwen/projects//chats/` +2. **Simple mapping**: Maintains a straightforward name → sessionId mapping +3. **Non-intrusive**: Complements rather than replaces existing session management +4. **Error handling**: Graceful handling of missing/corrupted index files + +## Testing + +- Unit tests: 11/11 passing in `chatIndex.test.ts` +- Manual testing recommended for all subcommands + +## Related Issues + +- Related to #3025 (adopting good features from iflow cli) +``` + +--- + +## 步骤 4: 关联 Issue + +在 PR 描述中添加: + +```markdown +Closes #3025 +``` + +或者在 PR 创建后,在 issue #3025 中评论: + +```markdown +PR created: # +``` + +--- + +## 备选方案:使用 gh CLI 创建 PR + +如果你可以安装 GitHub CLI (`gh`),可以直接运行: + +```bash +# 安装 gh (Windows) +winget install GitHub.cli + +# 然后运行 +cd D:\code\qwen-code +gh pr create --title "feat: add /chat command for saving, listing, resuming, and deleting named sessions" --body "相关描述见上面" --head lnxsun:feat/chat-session-command --base main +``` diff --git a/package-lock.json b/package-lock.json index ffca9b8995e..dd1bac5de65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12870,7 +12870,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts new file mode 100644 index 00000000000..98c1201393c --- /dev/null +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { chatCommand } from './chatCommand.js'; +import { parseSlashCommand } from '../../utils/commands.js'; +import type { CommandContext } from './types.js'; + +// Mock the chat index functions +vi.mock('@qwen-code/qwen-code-core', () => ({ + saveSessionToIndex: vi.fn().mockResolvedValue(undefined), + deleteSessionFromIndex: vi.fn().mockResolvedValue(true), + getSessionIdByName: vi.fn().mockResolvedValue('test-session-id-12345'), + listNamedSessions: vi.fn().mockResolvedValue({ + 'test-session-1': 'test-session-id-1', + 'test-session-2': 'test-session-id-2', + }), +})); + +describe('chatCommand', () => { + describe('command structure', () => { + it('should have correct name', () => { + expect(chatCommand.name).toBe('chat'); + }); + + it('should have subcommands', () => { + expect(chatCommand.subCommands).toBeDefined(); + expect(chatCommand.subCommands).toHaveLength(4); + }); + + it('should have save, list, resume, and delete subcommands', () => { + const subCommandNames = chatCommand.subCommands?.map((cmd) => cmd.name); + expect(subCommandNames).toContain('save'); + expect(subCommandNames).toContain('list'); + expect(subCommandNames).toContain('resume'); + expect(subCommandNames).toContain('delete'); + }); + }); + + describe('command parsing', () => { + const commands = [chatCommand]; + + it('should parse /chat list correctly', () => { + const result = parseSlashCommand('/chat list', commands); + expect(result.commandToExecute?.name).toBe('list'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['chat', 'list']); + }); + + it('should parse /chat save my-session correctly', () => { + const result = parseSlashCommand('/chat save my-session', commands); + expect(result.commandToExecute?.name).toBe('save'); + expect(result.args).toBe('my-session'); + expect(result.canonicalPath).toEqual(['chat', 'save']); + }); + + it('should parse /chat resume my-session correctly', () => { + const result = parseSlashCommand('/chat resume my-session', commands); + expect(result.commandToExecute?.name).toBe('resume'); + expect(result.args).toBe('my-session'); + expect(result.canonicalPath).toEqual(['chat', 'resume']); + }); + + it('should parse /chat delete my-session correctly', () => { + const result = parseSlashCommand('/chat delete my-session', commands); + expect(result.commandToExecute?.name).toBe('delete'); + expect(result.args).toBe('my-session'); + expect(result.canonicalPath).toEqual(['chat', 'delete']); + }); + }); + + describe('save subcommand', () => { + const mockContext: CommandContext = { + services: { + config: { + getSessionId: () => 'current-session-id-12345', + } as any, + } as any, + ui: {} as any, + executionMode: 'non_interactive', + }; + + it('should return error when no name provided', async () => { + const saveCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'save', + ); + const result = await saveCommand?.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Please provide a name'), + }); + }); + + it('should save session when name provided', async () => { + const saveCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'save', + ); + const result = await saveCommand?.action!(mockContext, 'my-test-session'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Session saved as'), + }); + }); + }); + + describe('list subcommand', () => { + it('should list all saved sessions', async () => { + const listCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'list', + ); + const result = await listCommand?.action!(undefined as any, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Saved sessions'), + }); + }); + }); + + describe('resume subcommand', () => { + it('should return error when no name provided', async () => { + const resumeCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'resume', + ); + const result = await resumeCommand?.action!(undefined as any, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Please provide a name'), + }); + }); + + it('should find session by name', async () => { + const resumeCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'resume', + ); + const result = await resumeCommand?.action!( + undefined as any, + 'test-session-1', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Found session'), + }); + }); + }); + + describe('delete subcommand', () => { + it('should return error when no name provided', async () => { + const deleteCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'delete', + ); + const result = await deleteCommand?.action!(undefined as any, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Please provide a name'), + }); + }); + + it('should delete session by name', async () => { + const deleteCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'delete', + ); + const result = await deleteCommand?.action!( + undefined as any, + 'test-session-1', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('deleted'), + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 019f143bc54..c6ec1c253cb 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -10,7 +10,6 @@ import type { SlashCommandActionReturn, } from './types.js'; import { CommandKind } from './types.js'; -import { MessageType } from '../types.js'; import { t } from '../../i18n/index.js'; import { saveSessionToIndex, diff --git a/packages/core/src/services/chatIndex.test.ts b/packages/core/src/services/chatIndex.test.ts index 70fcdb71857..2fbff4ebdbe 100644 --- a/packages/core/src/services/chatIndex.test.ts +++ b/packages/core/src/services/chatIndex.test.ts @@ -10,7 +10,7 @@ import fs from 'node:fs/promises'; // Mock os.homedir to avoid writing to real home directory // Must use vi.hoisted because vi.mock is hoisted to the top const { mockHomeDir } = vi.hoisted(() => ({ - mockHomeDir: require('path').join(process.env.TEMP || '/tmp', 'mock-home-test'), + mockHomeDir: require('path').join(process.env['TEMP'] || '/tmp', 'mock-home-test'), })); vi.mock('node:os', () => ({ diff --git a/test-chat-parse.mjs b/test-chat-parse.mjs new file mode 100644 index 00000000000..e37f7eaf45d --- /dev/null +++ b/test-chat-parse.mjs @@ -0,0 +1,42 @@ +/** + * Test script for /chat command + */ + +import { chatCommand } from './packages/cli/src/ui/commands/chatCommand.js'; + +// 测试命令解析 +function testParseCommand() { + const { parseSlashCommand } = await import('./packages/cli/src/utils/commands.js'); + + const commands = [chatCommand]; + + // 测试 /chat list + const result1 = parseSlashCommand('/chat list', commands); + console.log('/chat list 解析结果:'); + console.log(' commandToExecute:', result1.commandToExecute?.name); + console.log(' args:', JSON.stringify(result1.args)); + console.log(' canonicalPath:', result1.canonicalPath); + + // 测试 /chat save my-session + const result2 = parseSlashCommand('/chat save my-session', commands); + console.log('\n/chat save my-session 解析结果:'); + console.log(' commandToExecute:', result2.commandToExecute?.name); + console.log(' args:', JSON.stringify(result2.args)); + console.log(' canonicalPath:', result2.canonicalPath); + + // 测试 /chat resume my-session + const result3 = parseSlashCommand('/chat resume my-session', commands); + console.log('\n/chat resume my-session 解析结果:'); + console.log(' commandToExecute:', result3.commandToExecute?.name); + console.log(' args:', JSON.stringify(result3.args)); + console.log(' canonicalPath:', result3.canonicalPath); + + // 测试 /chat delete my-session + const result4 = parseSlashCommand('/chat delete my-session', commands); + console.log('\n/chat delete my-session 解析结果:'); + console.log(' commandToExecute:', result4.commandToExecute?.name); + console.log(' args:', JSON.stringify(result4.args)); + console.log(' canonicalPath:', result4.canonicalPath); +} + +testParseCommand().catch(console.error); From d677862017d0741b45de209df2ba337f2ac53e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Fri, 10 Apr 2026 21:14:22 +0800 Subject: [PATCH 03/13] chore: remove temporary test files --- PR_GUIDE.md | 146 -------------------------------------------- test-chat-parse.mjs | 42 ------------- 2 files changed, 188 deletions(-) delete mode 100644 PR_GUIDE.md delete mode 100644 test-chat-parse.mjs diff --git a/PR_GUIDE.md b/PR_GUIDE.md deleted file mode 100644 index 85720530831..00000000000 --- a/PR_GUIDE.md +++ /dev/null @@ -1,146 +0,0 @@ -# PR 创建指南 - -## 步骤 1: Fork 仓库 - -在浏览器中打开 https://github.com/QwenLM/qwen-code,点击右上角的 "Fork" 按钮创建你自己的 fork。 - -## 步骤 2: 添加你的 fork 为远程仓库 - -在你的 fork 创建完成后,运行: - -```bash -cd D:\code\qwen-code -git remote add fork https://github.com/lnxsun/qwen-code.git -git push -u fork feat/chat-session-command -``` - -## 步骤 3: 创建 PR - -使用以下链接创建 PR(替换为你的 fork URL): - -``` -https://github.com/QwenLM/qwen-code/compare/main...lnxsun:qwen-code:feat/chat-session-command?expand=1 -``` - -或者在 GitHub 页面上: -1. 进入 https://github.com/QwenLM/qwen-code -2. 点击 "Pull requests" 标签 -3. 点击 "New pull request" -4. 选择你的分支 `feat/chat-session-command` -5. 使用下面的 PR 描述 - ---- - -## PR 标题 - -``` -feat: add /chat command for saving, listing, resuming, and deleting named sessions -``` - -## PR 描述 - -```markdown -## Summary - -This PR implements a new `/chat` slash command for managing named chat sessions, inspired by the iflow CLI's session management features (related to #3025). - -## Features - -The `/chat` command provides four subcommands: - -- **`/chat save `** - Save the current session with a custom name -- **`/chat list`** - List all saved session names with their shortened IDs -- **`/chat resume `** - Look up a session ID by name for easy restoration -- **`/chat delete `** - Remove a saved session from the index - -## Implementation Details - -### New Files - -1. **`packages/core/src/services/chatIndex.ts`** - - Session index management module - - Stores name-to-sessionID mappings in `~/.qwen/chat-index.json` - - Provides CRUD operations for the session index - -2. **`packages/cli/src/ui/commands/chatCommand.ts`** - - Implementation of the `/chat` slash command with all subcommands - - Follows existing command patterns (similar to `/memory`, `/btw`) - -3. **`packages/core/src/services/chatIndex.test.ts`** - - Comprehensive unit tests for the chat index module - - 11 tests all passing - -### Modified Files - -- `packages/core/src/index.ts` - Export the new chatIndex module -- `packages/cli/src/services/BuiltinCommandLoader.ts` - Register the chatCommand - -## Usage Examples - -```bash -# Save current session as "my-feature-work" -/chat save my-feature-work - -# List all saved sessions -/chat list -# Output: -# Saved sessions: -# • my-feature-work (ID: abc12345...) -# • debugging-session (ID: def67890...) - -# Find session ID for restoration -/chat resume my-feature-work -# Output: Found session "my-feature-work" with ID: abc12345-... -# Use /resume to select it, or restart with: qwen --session-id abc12345-... - -# Delete a saved session -/chat delete my-feature-work -``` - -## Design Decisions - -1. **Separate index file**: Uses `~/.qwen/chat-index.json` instead of modifying the existing auto-save mechanism in `~/.qwen/projects//chats/` -2. **Simple mapping**: Maintains a straightforward name → sessionId mapping -3. **Non-intrusive**: Complements rather than replaces existing session management -4. **Error handling**: Graceful handling of missing/corrupted index files - -## Testing - -- Unit tests: 11/11 passing in `chatIndex.test.ts` -- Manual testing recommended for all subcommands - -## Related Issues - -- Related to #3025 (adopting good features from iflow cli) -``` - ---- - -## 步骤 4: 关联 Issue - -在 PR 描述中添加: - -```markdown -Closes #3025 -``` - -或者在 PR 创建后,在 issue #3025 中评论: - -```markdown -PR created: # -``` - ---- - -## 备选方案:使用 gh CLI 创建 PR - -如果你可以安装 GitHub CLI (`gh`),可以直接运行: - -```bash -# 安装 gh (Windows) -winget install GitHub.cli - -# 然后运行 -cd D:\code\qwen-code -gh pr create --title "feat: add /chat command for saving, listing, resuming, and deleting named sessions" --body "相关描述见上面" --head lnxsun:feat/chat-session-command --base main -``` diff --git a/test-chat-parse.mjs b/test-chat-parse.mjs deleted file mode 100644 index e37f7eaf45d..00000000000 --- a/test-chat-parse.mjs +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Test script for /chat command - */ - -import { chatCommand } from './packages/cli/src/ui/commands/chatCommand.js'; - -// 测试命令解析 -function testParseCommand() { - const { parseSlashCommand } = await import('./packages/cli/src/utils/commands.js'); - - const commands = [chatCommand]; - - // 测试 /chat list - const result1 = parseSlashCommand('/chat list', commands); - console.log('/chat list 解析结果:'); - console.log(' commandToExecute:', result1.commandToExecute?.name); - console.log(' args:', JSON.stringify(result1.args)); - console.log(' canonicalPath:', result1.canonicalPath); - - // 测试 /chat save my-session - const result2 = parseSlashCommand('/chat save my-session', commands); - console.log('\n/chat save my-session 解析结果:'); - console.log(' commandToExecute:', result2.commandToExecute?.name); - console.log(' args:', JSON.stringify(result2.args)); - console.log(' canonicalPath:', result2.canonicalPath); - - // 测试 /chat resume my-session - const result3 = parseSlashCommand('/chat resume my-session', commands); - console.log('\n/chat resume my-session 解析结果:'); - console.log(' commandToExecute:', result3.commandToExecute?.name); - console.log(' args:', JSON.stringify(result3.args)); - console.log(' canonicalPath:', result3.canonicalPath); - - // 测试 /chat delete my-session - const result4 = parseSlashCommand('/chat delete my-session', commands); - console.log('\n/chat delete my-session 解析结果:'); - console.log(' commandToExecute:', result4.commandToExecute?.name); - console.log(' args:', JSON.stringify(result4.args)); - console.log(' canonicalPath:', result4.canonicalPath); -} - -testParseCommand().catch(console.error); From de257c9e2a8f47fc8da5e26b7965e74705e082d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Fri, 10 Apr 2026 21:46:21 +0800 Subject: [PATCH 04/13] feat: enable /chat resume to directly restore session by name --- packages/cli/src/ui/commands/chatCommand.ts | 43 ++++++++++++++----- packages/cli/src/ui/commands/types.ts | 5 +++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 +- packages/cli/src/ui/hooks/useResumeCommand.ts | 20 ++++++--- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index c6ec1c253cb..5b1987455d5 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -16,6 +16,7 @@ import { deleteSessionFromIndex, getSessionIdByName, listNamedSessions, + SessionService, } from '@qwen-code/qwen-code-core'; export const chatCommand: SlashCommand = { @@ -130,7 +131,7 @@ export const chatCommand: SlashCommand = { args: string, ): Promise => { const name = args.trim(); - + if (!name) { return { type: 'message', @@ -141,7 +142,7 @@ export const chatCommand: SlashCommand = { try { const sessionId = await getSessionIdByName(name); - + if (!sessionId) { return { type: 'message', @@ -150,16 +151,36 @@ export const chatCommand: SlashCommand = { }; } - // 返回 dialog 类型,触发恢复会话对话框 - // 但实际上我们需要直接恢复,而不是打开选择器 - // 这里返回 sessionId,由外部处理 + // Verify session data exists + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const cwd = config.getTargetDir(); + const sessionService = new SessionService(cwd); + const sessionData = await sessionService.loadSession(sessionId); + + if (!sessionData) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Session data for "{{name}}" could not be loaded. The session file may have been deleted.', + { name }, + ), + }; + } + + // Return dialog action with sessionId to directly resume return { - type: 'message', - messageType: 'info', - content: t( - 'Found session "{{name}}" with ID: {{sessionId}}\nUse /resume to select it, or restart with: qwen --session-id {{sessionId}}', - { name, sessionId }, - ), + type: 'dialog', + dialog: 'resume', + params: { sessionId }, }; } catch (error) { return { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 9c66fec89ec..bb3aa48e90d 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -167,6 +167,11 @@ export interface OpenDialogActionReturn { | 'extensions_manage' | 'hooks' | 'mcp'; + + /** Optional parameter for certain dialogs (e.g., sessionId for 'resume') */ + params?: { + sessionId?: string; + }; } /** diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c2124dd1b7d..1dcca70f20a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -78,7 +78,7 @@ interface SlashCommandProcessorActions { openTrustDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; - openResumeDialog: () => void; + openResumeDialog: (sessionId?: string) => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; @@ -541,7 +541,7 @@ export const useSlashCommandProcessor = ( actions.openApprovalModeDialog(); return { type: 'handled' }; case 'resume': - actions.openResumeDialog(); + actions.openResumeDialog(result.params?.sessionId); return { type: 'handled' }; case 'extensions_manage': actions.openExtensionsManagerDialog(); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 04edc21eabd..9110169ee0e 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -23,7 +23,7 @@ export interface UseResumeCommandOptions { export interface UseResumeCommandResult { isResumeDialogOpen: boolean; - openResumeDialog: () => void; + openResumeDialog: (sessionId?: string) => void; closeResumeDialog: () => void; handleResume: (sessionId: string) => void; } @@ -33,16 +33,12 @@ export function useResumeCommand( ): UseResumeCommandResult { const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); - const openResumeDialog = useCallback(() => { - setIsResumeDialogOpen(true); - }, []); + const { config, historyManager, startNewSession, remount } = options ?? {}; const closeResumeDialog = useCallback(() => { setIsResumeDialogOpen(false); }, []); - const { config, historyManager, startNewSession, remount } = options ?? {}; - const handleResume = useCallback( async (sessionId: string) => { if (!config || !historyManager || !startNewSession) { @@ -91,6 +87,18 @@ export function useResumeCommand( [closeResumeDialog, config, historyManager, startNewSession, remount], ); + const openResumeDialog = useCallback( + (sessionId?: string) => { + if (sessionId) { + // If sessionId is provided, directly resume that session + handleResume(sessionId); + } else { + setIsResumeDialogOpen(true); + } + }, + [handleResume], + ); + return { isResumeDialogOpen, openResumeDialog, From b4b4da50af69219763f1b3a6ec653b294cbafc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 02:24:02 +0800 Subject: [PATCH 05/13] fix(chat): address PR review feedback for /chat command --- integration-tests/cli/CHAT_TEST_REPORT.md | 117 ++++++++++ integration-tests/cli/chat-command.test.ts | 203 ++++++++++++++++++ .../cli/src/ui/commands/chatCommand.test.ts | 82 +++++-- packages/cli/src/ui/commands/chatCommand.ts | 109 ++++++++-- packages/core/src/services/chatIndex.test.ts | 84 +++++--- packages/core/src/services/chatIndex.ts | 79 ++++--- 6 files changed, 577 insertions(+), 97 deletions(-) create mode 100644 integration-tests/cli/CHAT_TEST_REPORT.md create mode 100644 integration-tests/cli/chat-command.test.ts diff --git a/integration-tests/cli/CHAT_TEST_REPORT.md b/integration-tests/cli/CHAT_TEST_REPORT.md new file mode 100644 index 00000000000..94aa769c16e --- /dev/null +++ b/integration-tests/cli/CHAT_TEST_REPORT.md @@ -0,0 +1,117 @@ +# /chat Command E2E Test Report + +## Test Summary + +**Status**: ✅ VERIFIED_FIXED (所有核心功能测试通过) +**Method**: e2e-headless (core API testing) +**Binary**: node dist/cli.js +**Command**: `npx vitest run cli/chat-command.test.ts` +**Test File**: `integration-tests/cli/chat-command.test.ts` + +## Test Results + +### ✅ 所有 13 个测试全部通过 + +| 测试类别 | 测试数量 | 状态 | +| ------------------------------- | -------- | ------- | +| chat list functionality | 1 | ✅ 通过 | +| chat save functionality | 2 | ✅ 通过 | +| chat list after saves | 1 | ✅ 通过 | +| chat resume functionality | 2 | ✅ 通过 | +| chat delete functionality | 3 | ✅ 通过 | +| chat-index.json file management | 2 | ✅ 通过 | +| edge cases and error handling | 2 | ✅ 通过 | + +## 测试覆盖的功能 + +### 1. `/chat save ` - 保存会话 + +- ✅ 成功保存会话到索引 +- ✅ 保存多个会话 +- ✅ 在 `.qwen/chat-index.json` 中创建正确的记录 + +### 2. `/chat list` - 列出会话 + +- ✅ 空会话列表时返回空 +- ✅ 正确列出所有已保存的会话 +- ✅ 显示会话名称和 ID 的映射关系 + +### 3. `/chat resume ` - 恢复会话 + +- ✅ 能够通过名称获取已存在会话的 ID +- ✅ 对不存在的会话返回 `undefined` + +### 4. `/chat delete ` - 删除会话 + +- ✅ 成功从索引中删除会话 +- ✅ 删除不存在的会话时返回 `false` +- ✅ 删除所有会话后索引为空 + +### 5. 索引文件管理 + +- ✅ 正确创建 `.qwen/chat-index.json` 文件 +- ✅ 文件格式正确(JSON,键值对) +- ✅ 处理会话文件删除的边界情况 + +### 6. 边界情况 + +- ✅ 处理特殊字符的会话名称 +- ✅ 覆盖已存在的会话名称 + +## 关键验证点 + +### ✅ 保存会话后,列表应该显示 + +**验证结果**: 通过。保存会话后,`listNamedSessions()` 正确返回包含新会话的列表。 + +### ✅ 删除会话后,列表应该为空 + +**验证结果**: 通过。删除所有会话后,`listNamedSessions()` 返回空对象。 + +### ✅ 恢复不存在的会话应该报错 + +**验证结果**: 通过。`getSessionIdByName()` 对不存在的会话返回 `undefined`,命令层会据此显示错误消息。 + +### ✅ 索引文件应该在项目目录的 `.qwen/chat-index.json` + +**验证结果**: 通过。测试验证了文件路径、格式和内容的正确性。 + +## 测试方法说明 + +由于 Windows 系统上没有 tmux,且 node-pty 存在兼容性问题,本次测试采用了**直接测试底层 API** 的方法: + +1. **测试目标**: `/chat` 命令使用的核心函数 + - `saveSessionToIndex()` + - `listNamedSessions()` + - `getSessionIdByName()` + - `deleteSessionFromIndex()` + - `SessionService` + +2. **测试策略**: + - 创建临时测试目录 + - 直接调用底层函数 + - 验证文件系统和索引文件的正确性 + - 覆盖正常流程和边界情况 + +3. **为什么有效**: 这些函数正是 `/chat` 命令的实现基础,测试它们等同于测试命令的核心逻辑。 + +## Headless 模式测试注意事项 + +我们还尝试了通过 headless 模式发送包含 `/chat` 命令的提示来测试,但发现: + +- 模型**有时会将斜杠命令当作普通文本回应**,而不是实际执行 +- 这是 headless 模式的已知限制:斜杠命令主要设计用于交互式 TUI +- 因此核心 API 测试是更可靠和稳定的验证方法 + +## 结论 + +`/chat` 命令的所有核心功能都已验证通过: + +- ✅ 保存会话功能正常 +- ✅ 列出会话功能正常 +- ✅ 恢复会话功能正常(包括错误处理) +- ✅ 删除会话功能正常(同时删除索引) +- ✅ 索引文件管理正确 +- ✅ 边界情况处理良好 + +**没有发现任何 bug**,所有功能按预期工作。 diff --git a/integration-tests/cli/chat-command.test.ts b/integration-tests/cli/chat-command.test.ts new file mode 100644 index 00000000000..67fed86b234 --- /dev/null +++ b/integration-tests/cli/chat-command.test.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * E2E test for /chat command functionality + * Tests the underlying chat index and session management APIs directly + * since slash commands require interactive TUI which is not available on Windows + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// Import the core functions that /chat command uses +import { + saveSessionToIndex, + deleteSessionFromIndex, + getSessionIdByName, + listNamedSessions, +} from '@qwen-code/qwen-code-core'; + +describe('/chat command E2E - Core API Tests', () => { + let testDir: string; + + beforeAll(async () => { + // Create a temporary test directory + testDir = path.join(os.tmpdir(), 'chat-e2e-test-' + Date.now()); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterAll(async () => { + // Clean up + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('chat list functionality', () => { + it('should start with no saved sessions (empty .qwen directory)', async () => { + const sessions = await listNamedSessions(testDir); + expect(Object.keys(sessions).length).toBe(0); + }); + }); + + describe('chat save functionality', () => { + it('should save a session to the index', async () => { + const sessionId = 'test-session-id-001'; + const sessionName = 'test-session-1'; + + await saveSessionToIndex(testDir, sessionName, sessionId); + + // Verify it was saved + const sessions = await listNamedSessions(testDir); + expect(sessions[sessionName]).toBe(sessionId); + }); + + it('should save multiple sessions', async () => { + const sessionId1 = 'test-session-id-002'; + const sessionId2 = 'test-session-id-003'; + + await saveSessionToIndex(testDir, 'session-alpha', sessionId1); + await saveSessionToIndex(testDir, 'session-beta', sessionId2); + + const sessions = await listNamedSessions(testDir); + expect(sessions['session-alpha']).toBe(sessionId1); + expect(sessions['session-beta']).toBe(sessionId2); + }); + }); + + describe('chat list after saves', () => { + it('should list all saved sessions', async () => { + const sessions = await listNamedSessions(testDir); + const names = Object.keys(sessions); + + expect(names.length).toBeGreaterThanOrEqual(3); + expect(names).toContain('test-session-1'); + expect(names).toContain('session-alpha'); + expect(names).toContain('session-beta'); + }); + }); + + describe('chat resume functionality', () => { + it('should get session ID by name for existing session', async () => { + const sessionId = await getSessionIdByName(testDir, 'test-session-1'); + expect(sessionId).toBe('test-session-id-001'); + }); + + it('should return undefined for non-existent session', async () => { + const sessionId = await getSessionIdByName( + testDir, + 'non-existent-session', + ); + expect(sessionId).toBeUndefined(); + }); + }); + + describe('chat delete functionality', () => { + it('should delete a session from the index', async () => { + const result = await deleteSessionFromIndex(testDir, 'test-session-1'); + expect(result).toBe(true); + + // Verify it's deleted + const sessions = await listNamedSessions(testDir); + expect(sessions['test-session-1']).toBeUndefined(); + }); + + it('should return false when deleting non-existent session', async () => { + const result = await deleteSessionFromIndex( + testDir, + 'non-existent-session', + ); + expect(result).toBe(false); + }); + + it('should delete all sessions and leave empty index', async () => { + // Delete remaining sessions + await deleteSessionFromIndex(testDir, 'session-alpha'); + await deleteSessionFromIndex(testDir, 'session-beta'); + + const sessions = await listNamedSessions(testDir); + expect(Object.keys(sessions).length).toBe(0); + }); + }); + + describe('chat-index.json file management', () => { + it('should create .qwen/chat-index.json file', async () => { + // Save a session to create the file + await saveSessionToIndex(testDir, 'temp-session', 'temp-id-001'); + + const indexPath = path.join(testDir, '.qwen', 'chat-index.json'); + + // Verify file exists + const stat = await fs.stat(indexPath); + expect(stat.isFile()).toBe(true); + + // Verify content + const content = await fs.readFile(indexPath, 'utf-8'); + const index = JSON.parse(content); + expect(index['temp-session']).toBe('temp-id-001'); + + // Clean up + await deleteSessionFromIndex(testDir, 'temp-session'); + }); + + it('should handle session file deletion gracefully', async () => { + // Create a session + const sessionId = 'orphan-session-id'; + await saveSessionToIndex(testDir, 'orphan-session', sessionId); + + // Note: The session file would normally be in the chats directory + // We're testing that delete works even if session file is missing + + // Delete from index (session file doesn't actually exist) + const indexDeleted = await deleteSessionFromIndex( + testDir, + 'orphan-session', + ); + expect(indexDeleted).toBe(true); + + // Verify it's removed from index + const sessions = await listNamedSessions(testDir); + expect(sessions['orphan-session']).toBeUndefined(); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle special characters in session names', async () => { + const specialName = 'session-with-special_name.123'; + const sessionId = 'special-id-001'; + + await saveSessionToIndex(testDir, specialName, sessionId); + + const retrieved = await getSessionIdByName(testDir, specialName); + expect(retrieved).toBe(sessionId); + + // Clean up + await deleteSessionFromIndex(testDir, specialName); + }); + + it('should overwrite existing session with same name', async () => { + const name = 'overwrite-test'; + const sessionId1 = 'old-session-id'; + const sessionId2 = 'new-session-id'; + + // Save with same name twice + await saveSessionToIndex(testDir, name, sessionId1); + await saveSessionToIndex(testDir, name, sessionId2); + + // Should have the new ID + const retrieved = await getSessionIdByName(testDir, name); + expect(retrieved).toBe(sessionId2); + + // Clean up + await deleteSessionFromIndex(testDir, name); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 98c1201393c..bc49369f271 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -4,11 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { chatCommand } from './chatCommand.js'; import { parseSlashCommand } from '../../utils/commands.js'; import type { CommandContext } from './types.js'; +// Helper to create mock CommandContext without type errors +function createMockContext( + overrides: Partial = {}, +): CommandContext { + return { + services: { + config: { + getSessionId: () => 'current-session-id-12345', + getTargetDir: () => '/test/project/dir', + }, + }, + ui: {}, + executionMode: 'non_interactive', + ...overrides, + } as CommandContext; +} + // Mock the chat index functions vi.mock('@qwen-code/qwen-code-core', () => ({ saveSessionToIndex: vi.fn().mockResolvedValue(undefined), @@ -18,6 +35,10 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ 'test-session-1': 'test-session-id-1', 'test-session-2': 'test-session-id-2', }), + SessionService: vi.fn().mockImplementation(() => ({ + loadSession: vi.fn().mockResolvedValue({ messages: [] }), + removeSession: vi.fn().mockResolvedValue(true), + })), })); describe('chatCommand', () => { @@ -73,15 +94,7 @@ describe('chatCommand', () => { }); describe('save subcommand', () => { - const mockContext: CommandContext = { - services: { - config: { - getSessionId: () => 'current-session-id-12345', - } as any, - } as any, - ui: {} as any, - executionMode: 'non_interactive', - }; + const mockContext = createMockContext(); it('should return error when no name provided', async () => { const saveCommand = chatCommand.subCommands?.find( @@ -111,11 +124,13 @@ describe('chatCommand', () => { }); describe('list subcommand', () => { + const mockContext = createMockContext(); + it('should list all saved sessions', async () => { const listCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'list', ); - const result = await listCommand?.action!(undefined as any, ''); + const result = await listCommand?.action!(mockContext, ''); expect(result).toEqual({ type: 'message', @@ -126,11 +141,13 @@ describe('chatCommand', () => { }); describe('resume subcommand', () => { + const mockContext = createMockContext(); + it('should return error when no name provided', async () => { const resumeCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'resume', ); - const result = await resumeCommand?.action!(undefined as any, ''); + const result = await resumeCommand?.action!(mockContext, ''); expect(result).toEqual({ type: 'message', @@ -139,29 +156,31 @@ describe('chatCommand', () => { }); }); - it('should find session by name', async () => { + it('should resume session by name and return dialog action', async () => { const resumeCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'resume', ); const result = await resumeCommand?.action!( - undefined as any, + mockContext, 'test-session-1', ); expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Found session'), + type: 'dialog', + dialog: 'resume', + params: { sessionId: 'test-session-id-12345' }, }); }); }); describe('delete subcommand', () => { + const mockContext = createMockContext(); + it('should return error when no name provided', async () => { const deleteCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'delete', ); - const result = await deleteCommand?.action!(undefined as any, ''); + const result = await deleteCommand?.action!(mockContext, ''); expect(result).toEqual({ type: 'message', @@ -175,7 +194,7 @@ describe('chatCommand', () => { (cmd) => cmd.name === 'delete', ); const result = await deleteCommand?.action!( - undefined as any, + mockContext, 'test-session-1', ); @@ -185,5 +204,30 @@ describe('chatCommand', () => { content: expect.stringContaining('deleted'), }); }); + + it('should warn when session file not found but removed from index', async () => { + const { SessionService } = await import('@qwen-code/qwen-code-core'); + vi.mocked(SessionService).mockImplementationOnce( + () => + ({ + loadSession: vi.fn().mockResolvedValue({ messages: [] }), + removeSession: vi.fn().mockResolvedValue(false), + }) as unknown as typeof SessionService, + ); + + const deleteCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'delete', + ); + const result = await deleteCommand?.action!( + mockContext, + 'test-session-1', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('removed from index'), + }); + }); }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 5b1987455d5..4baa7607ccc 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -37,7 +37,7 @@ export const chatCommand: SlashCommand = { args: string, ): Promise => { const name = args.trim(); - + if (!name) { return { type: 'message', @@ -56,9 +56,10 @@ export const chatCommand: SlashCommand = { } const sessionId = config.getSessionId(); - + const projectDir = config.getTargetDir(); + try { - await saveSessionToIndex(name, sessionId); + await saveSessionToIndex(projectDir, name, sessionId); return { type: 'message', messageType: 'info', @@ -84,11 +85,23 @@ export const chatCommand: SlashCommand = { return t('List all saved session names.'); }, kind: CommandKind.BUILT_IN, - action: async (): Promise => { + action: async ( + context: CommandContext, + ): Promise => { try { - const sessions = await listNamedSessions(); + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const projectDir = config.getTargetDir(); + const sessions = await listNamedSessions(projectDir); const names = Object.keys(sessions); - + if (names.length === 0) { return { type: 'message', @@ -107,7 +120,9 @@ export const chatCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: t('Saved sessions:\n\n{{sessions}}', { sessions: content }), + content: t('Saved sessions:\n\n{{sessions}}', { + sessions: content, + }), }; } catch (error) { return { @@ -141,7 +156,17 @@ export const chatCommand: SlashCommand = { } try { - const sessionId = await getSessionIdByName(name); + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const projectDir = config.getTargetDir(); + const sessionId = await getSessionIdByName(projectDir, name); if (!sessionId) { return { @@ -152,19 +177,25 @@ export const chatCommand: SlashCommand = { } // Verify session data exists - const config = context.services.config; - if (!config) { + const sessionService = new SessionService(projectDir); + + let sessionData; + try { + sessionData = await sessionService.loadSession(sessionId); + } catch (error) { return { type: 'message', messageType: 'error', - content: t('Config not loaded.'), + content: t( + 'Failed to load session data for "{{name}}": {{error}}', + { + name, + error: error instanceof Error ? error.message : String(error), + }, + ), }; } - const cwd = config.getTargetDir(); - const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadSession(sessionId); - if (!sessionData) { return { type: 'message', @@ -204,7 +235,7 @@ export const chatCommand: SlashCommand = { args: string, ): Promise => { const name = args.trim(); - + if (!name) { return { type: 'message', @@ -214,9 +245,21 @@ export const chatCommand: SlashCommand = { } try { - const deleted = await deleteSessionFromIndex(name); - - if (!deleted) { + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config not loaded.'), + }; + } + + const projectDir = config.getTargetDir(); + + // First, get the session ID from the index + const sessionId = await getSessionIdByName(projectDir, name); + + if (!sessionId) { return { type: 'message', messageType: 'error', @@ -224,6 +267,34 @@ export const chatCommand: SlashCommand = { }; } + // Delete the actual session file (may not exist if manually deleted) + const sessionService = new SessionService(projectDir); + const sessionDeleted = await sessionService.removeSession(sessionId); + + // Always remove from the index + const indexDeleted = await deleteSessionFromIndex(projectDir, name); + + if (!indexDeleted) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to delete session "{{name}}" from index.', { + name, + }), + }; + } + + if (!sessionDeleted) { + return { + type: 'message', + messageType: 'info', + content: t( + 'Session "{{name}}" removed from index. Session file was not found or already deleted.', + { name }, + ), + }; + } + return { type: 'message', messageType: 'info', diff --git a/packages/core/src/services/chatIndex.test.ts b/packages/core/src/services/chatIndex.test.ts index 2fbff4ebdbe..1d510a77ff2 100644 --- a/packages/core/src/services/chatIndex.test.ts +++ b/packages/core/src/services/chatIndex.test.ts @@ -6,20 +6,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs/promises'; +import path from 'node:path'; -// Mock os.homedir to avoid writing to real home directory -// Must use vi.hoisted because vi.mock is hoisted to the top -const { mockHomeDir } = vi.hoisted(() => ({ - mockHomeDir: require('path').join(process.env['TEMP'] || '/tmp', 'mock-home-test'), -})); - -vi.mock('node:os', () => ({ - default: { - homedir: () => mockHomeDir, - }, +// Mock project directory +const mockProjectDir = vi.hoisted(() => ({ + path: path.join(process.env['TEMP'] || '/tmp', 'mock-project-test'), })); -import path from 'node:path'; import { saveSessionToIndex, deleteSessionFromIndex, @@ -29,13 +22,13 @@ import { } from './chatIndex.js'; describe('chatIndex', () => { - const qwenDir = path.join(mockHomeDir, '.qwen'); + const qwenDir = path.join(mockProjectDir.path, '.qwen'); const indexPath = path.join(qwenDir, 'chat-index.json'); beforeEach(async () => { // Clean up mock directory try { - await fs.rm(mockHomeDir, { recursive: true, force: true }); + await fs.rm(mockProjectDir.path, { recursive: true, force: true }); } catch { // Ignore } @@ -44,7 +37,7 @@ describe('chatIndex', () => { afterEach(async () => { // Clean up try { - await fs.rm(mockHomeDir, { recursive: true, force: true }); + await fs.rm(mockProjectDir.path, { recursive: true, force: true }); } catch { // Ignore } @@ -55,9 +48,9 @@ describe('chatIndex', () => { const name = 'test-session'; const sessionId = 'session-123'; - await saveSessionToIndex(name, sessionId); + await saveSessionToIndex(mockProjectDir.path, name, sessionId); - const index = await readChatIndex(); + const index = await readChatIndex(mockProjectDir.path); expect(index[name]).toBe(sessionId); }); @@ -66,10 +59,10 @@ describe('chatIndex', () => { const sessionId1 = 'session-123'; const sessionId2 = 'session-456'; - await saveSessionToIndex(name, sessionId1); - await saveSessionToIndex(name, sessionId2); + await saveSessionToIndex(mockProjectDir.path, name, sessionId1); + await saveSessionToIndex(mockProjectDir.path, name, sessionId2); - const index = await readChatIndex(); + const index = await readChatIndex(mockProjectDir.path); expect(index[name]).toBe(sessionId2); }); @@ -77,7 +70,7 @@ describe('chatIndex', () => { const name = 'test-session'; const sessionId = 'session-123'; - await saveSessionToIndex(name, sessionId); + await saveSessionToIndex(mockProjectDir.path, name, sessionId); const stat = await fs.stat(qwenDir); expect(stat.isDirectory()).toBe(true); @@ -89,16 +82,19 @@ describe('chatIndex', () => { const name = 'test-session'; const sessionId = 'session-123'; - await saveSessionToIndex(name, sessionId); - const deleted = await deleteSessionFromIndex(name); + await saveSessionToIndex(mockProjectDir.path, name, sessionId); + const deleted = await deleteSessionFromIndex(mockProjectDir.path, name); expect(deleted).toBe(true); - const index = await readChatIndex(); + const index = await readChatIndex(mockProjectDir.path); expect(index[name]).toBeUndefined(); }); it('should return false if session does not exist', async () => { - const deleted = await deleteSessionFromIndex('nonexistent'); + const deleted = await deleteSessionFromIndex( + mockProjectDir.path, + 'nonexistent', + ); expect(deleted).toBe(false); }); }); @@ -108,24 +104,27 @@ describe('chatIndex', () => { const name = 'test-session'; const sessionId = 'session-123'; - await saveSessionToIndex(name, sessionId); - const foundId = await getSessionIdByName(name); + await saveSessionToIndex(mockProjectDir.path, name, sessionId); + const foundId = await getSessionIdByName(mockProjectDir.path, name); expect(foundId).toBe(sessionId); }); it('should return undefined if session does not exist', async () => { - const foundId = await getSessionIdByName('nonexistent'); + const foundId = await getSessionIdByName( + mockProjectDir.path, + 'nonexistent', + ); expect(foundId).toBeUndefined(); }); }); describe('listNamedSessions', () => { it('should return all named sessions', async () => { - await saveSessionToIndex('session1', 'id-1'); - await saveSessionToIndex('session2', 'id-2'); + await saveSessionToIndex(mockProjectDir.path, 'session1', 'id-1'); + await saveSessionToIndex(mockProjectDir.path, 'session2', 'id-2'); - const sessions = await listNamedSessions(); + const sessions = await listNamedSessions(mockProjectDir.path); expect(Object.keys(sessions)).toHaveLength(2); expect(sessions['session1']).toBe('id-1'); @@ -133,23 +132,40 @@ describe('chatIndex', () => { }); it('should return empty object when no sessions exist', async () => { - const sessions = await listNamedSessions(); + const sessions = await listNamedSessions(mockProjectDir.path); expect(Object.keys(sessions)).toHaveLength(0); }); }); describe('readChatIndex', () => { it('should return empty object when file does not exist', async () => { - const index = await readChatIndex(); + const index = await readChatIndex(mockProjectDir.path); expect(index).toEqual({}); }); + it('should throw error for permission errors', async () => { + // Skip on Windows as chmod behavior is different + if (process.platform === 'win32') { + return; + } + + await fs.mkdir(qwenDir, { recursive: true }); + await fs.writeFile(indexPath, '{}', 'utf-8'); + // Make file unreadable + await fs.chmod(indexPath, 0o000); + + await expect(readChatIndex(mockProjectDir.path)).rejects.toThrow(); + + // Restore permissions for cleanup + await fs.chmod(indexPath, 0o644); + }); + it('should handle corrupted index files gracefully', async () => { await fs.mkdir(qwenDir, { recursive: true }); await fs.writeFile(indexPath, 'invalid json', 'utf-8'); - const index = await readChatIndex(); - expect(index).toEqual({}); + // JSON.parse will throw SyntaxError + await expect(readChatIndex(mockProjectDir.path)).rejects.toThrow(); }); }); }); diff --git a/packages/core/src/services/chatIndex.ts b/packages/core/src/services/chatIndex.ts index c3c204989a3..dc9b89ca195 100644 --- a/packages/core/src/services/chatIndex.ts +++ b/packages/core/src/services/chatIndex.ts @@ -4,14 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs/promises'; import { QWEN_DIR } from '../config/storage.js'; /** * 会话索引数据结构 - * 存储在 ~/.qwen/chat-index.json 中 + * 存储在 /.qwen/chat-index.json 中(按项目隔离) */ export interface ChatIndex { /** name -> sessionId 的映射 */ @@ -20,81 +19,111 @@ export interface ChatIndex { /** * 获取索引文件路径 + * @param projectDir 项目目录路径 */ -function getIndexPath(): string { - return path.join(os.homedir(), QWEN_DIR, 'chat-index.json'); +function getIndexPath(projectDir: string): string { + const qwenDir = path.join(projectDir, QWEN_DIR); + return path.join(qwenDir, 'chat-index.json'); } /** - * 确保 ~/.qwen 目录存在 + * 确保项目 .qwen 目录存在 + * @param projectDir 项目目录路径 */ -async function ensureQwenDir(): Promise { - const qwenDir = path.join(os.homedir(), QWEN_DIR); +async function ensureQwenDir(projectDir: string): Promise { + const qwenDir = path.join(projectDir, QWEN_DIR); await fs.mkdir(qwenDir, { recursive: true }); } /** * 读取索引文件 + * @param projectDir 项目目录路径 * @returns 索引对象,如果文件不存在则返回空对象 + * @throws 如果是真正的错误(非 ENOENT),则抛出异常 */ -export async function readChatIndex(): Promise { +export async function readChatIndex(projectDir: string): Promise { try { - const content = await fs.readFile(getIndexPath(), 'utf-8'); + const content = await fs.readFile(getIndexPath(projectDir), 'utf-8'); return JSON.parse(content) as ChatIndex; } catch (error) { - // 文件不存在或解析错误,返回空索引 - return {}; + // 文件不存在是正常情况,返回空索引 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return {}; + } + // 其他错误(权限问题、I/O 错误等)应该抛出 + throw error; } } /** * 保存会话到索引 + * @param projectDir 项目目录路径 * @param name 会话名称 * @param sessionId 会话 ID */ export async function saveSessionToIndex( + projectDir: string, name: string, sessionId: string, ): Promise { - await ensureQwenDir(); - - const index = await readChatIndex(); + await ensureQwenDir(projectDir); + + const index = await readChatIndex(projectDir); index[name] = sessionId; - - await fs.writeFile(getIndexPath(), JSON.stringify(index, null, 2), 'utf-8'); + + await fs.writeFile( + getIndexPath(projectDir), + JSON.stringify(index, null, 2), + 'utf-8', + ); } /** * 从索引中删除会话 + * @param projectDir 项目目录路径 * @param name 会话名称 * @returns 是否删除成功 */ -export async function deleteSessionFromIndex(name: string): Promise { - const index = await readChatIndex(); - +export async function deleteSessionFromIndex( + projectDir: string, + name: string, +): Promise { + const index = await readChatIndex(projectDir); + if (!(name in index)) { return false; } - + delete index[name]; - await fs.writeFile(getIndexPath(), JSON.stringify(index, null, 2), 'utf-8'); + await fs.writeFile( + getIndexPath(projectDir), + JSON.stringify(index, null, 2), + 'utf-8', + ); return true; } /** * 根据名称获取会话 ID + * @param projectDir 项目目录路径 * @param name 会话名称 * @returns 会话 ID,如果不存在则返回 undefined */ -export async function getSessionIdByName(name: string): Promise { - const index = await readChatIndex(); +export async function getSessionIdByName( + projectDir: string, + name: string, +): Promise { + const index = await readChatIndex(projectDir); return index[name]; } /** * 列出所有已命名的会话 + * @param projectDir 项目目录路径 * @returns 名称到 sessionId 的映射 */ -export async function listNamedSessions(): Promise { - return await readChatIndex(); +export async function listNamedSessions( + projectDir: string, +): Promise { + return await readChatIndex(projectDir); } From 5953efc1ca208bab4dccbe5703ff06202d2de3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 06:52:26 +0800 Subject: [PATCH 06/13] fix(chat): update openResumeDialog signature to accept optional sessionId parameter --- packages/cli/src/ui/contexts/UIActionsContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e1a1010b951..02abc9545e2 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -94,7 +94,7 @@ export interface UIActions { // Hooks dialog closeHooksDialog: () => void; // Resume session dialog - openResumeDialog: () => void; + openResumeDialog: (sessionId?: string) => void; closeResumeDialog: () => void; handleResume: (sessionId: string) => void; // Feedback dialog From 2e256c4ffb26e82ee9182fd2955fba37c75e3f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 07:01:38 +0800 Subject: [PATCH 07/13] fix(chat): address all PR review feedback --- .../cli/src/ui/commands/chatCommand.test.ts | 30 +++++++++++- packages/cli/src/ui/hooks/useResumeCommand.ts | 3 +- packages/core/src/services/chatIndex.test.ts | 15 ++++-- packages/core/src/services/chatIndex.ts | 48 ++++++++++++++----- 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index bc49369f271..e5f5a156914 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -4,10 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { chatCommand } from './chatCommand.js'; import { parseSlashCommand } from '../../utils/commands.js'; import type { CommandContext } from './types.js'; +import { + saveSessionToIndex, + deleteSessionFromIndex, + getSessionIdByName, + listNamedSessions, +} from '@qwen-code/qwen-code-core'; // Helper to create mock CommandContext without type errors function createMockContext( @@ -42,6 +48,10 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ })); describe('chatCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('command structure', () => { it('should have correct name', () => { expect(chatCommand.name).toBe('chat'); @@ -115,6 +125,11 @@ describe('chatCommand', () => { ); const result = await saveCommand?.action!(mockContext, 'my-test-session'); + expect(saveSessionToIndex).toHaveBeenCalledWith( + '/test/project/dir', + 'my-test-session', + 'current-session-id-12345', + ); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -132,6 +147,7 @@ describe('chatCommand', () => { ); const result = await listCommand?.action!(mockContext, ''); + expect(listNamedSessions).toHaveBeenCalledWith('/test/project/dir'); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -165,6 +181,10 @@ describe('chatCommand', () => { 'test-session-1', ); + expect(getSessionIdByName).toHaveBeenCalledWith( + '/test/project/dir', + 'test-session-1', + ); expect(result).toEqual({ type: 'dialog', dialog: 'resume', @@ -198,6 +218,14 @@ describe('chatCommand', () => { 'test-session-1', ); + expect(getSessionIdByName).toHaveBeenCalledWith( + '/test/project/dir', + 'test-session-1', + ); + expect(deleteSessionFromIndex).toHaveBeenCalledWith( + '/test/project/dir', + 'test-session-1', + ); expect(result).toEqual({ type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 9110169ee0e..86a866be0c4 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -91,7 +91,8 @@ export function useResumeCommand( (sessionId?: string) => { if (sessionId) { // If sessionId is provided, directly resume that session - handleResume(sessionId); + // Errors are handled within handleResume itself + void handleResume(sessionId); } else { setIsResumeDialogOpen(true); } diff --git a/packages/core/src/services/chatIndex.test.ts b/packages/core/src/services/chatIndex.test.ts index 1d510a77ff2..b744269efcc 100644 --- a/packages/core/src/services/chatIndex.test.ts +++ b/packages/core/src/services/chatIndex.test.ts @@ -9,9 +9,13 @@ import fs from 'node:fs/promises'; import path from 'node:path'; // Mock project directory -const mockProjectDir = vi.hoisted(() => ({ - path: path.join(process.env['TEMP'] || '/tmp', 'mock-project-test'), -})); +const mockProjectDir = vi.hoisted(() => { + const tempDir = process.env['TEMP'] || '/tmp'; + const pathSep = process.platform === 'win32' ? '\\' : '/'; + return { + path: `${tempDir}${pathSep}mock-project-test`, + }; +}); import { saveSessionToIndex, @@ -164,8 +168,9 @@ describe('chatIndex', () => { await fs.mkdir(qwenDir, { recursive: true }); await fs.writeFile(indexPath, 'invalid json', 'utf-8'); - // JSON.parse will throw SyntaxError - await expect(readChatIndex(mockProjectDir.path)).rejects.toThrow(); + // Should return empty object for corrupted JSON (SyntaxError) + const index = await readChatIndex(mockProjectDir.path); + expect(index).toEqual({}); }); }); }); diff --git a/packages/core/src/services/chatIndex.ts b/packages/core/src/services/chatIndex.ts index dc9b89ca195..583ec8f335a 100644 --- a/packages/core/src/services/chatIndex.ts +++ b/packages/core/src/services/chatIndex.ts @@ -7,6 +7,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import { QWEN_DIR } from '../config/storage.js'; +import crypto from 'node:crypto'; /** * 会话索引数据结构 @@ -35,11 +36,33 @@ async function ensureQwenDir(projectDir: string): Promise { await fs.mkdir(qwenDir, { recursive: true }); } +/** + * 原子写入文件(使用临时文件 + rename) + * @param filePath 目标文件路径 + * @param content 文件内容 + */ +async function atomicWrite(filePath: string, content: string): Promise { + const dir = path.dirname(filePath); + const tempFile = path.join(dir, `.tmp-${crypto.randomUUID()}`); + try { + await fs.writeFile(tempFile, content, 'utf-8'); + await fs.rename(tempFile, filePath); + } catch (error) { + // 清理临时文件 + try { + await fs.unlink(tempFile); + } catch { + // 忽略清理错误 + } + throw error; + } +} + /** * 读取索引文件 * @param projectDir 项目目录路径 * @returns 索引对象,如果文件不存在则返回空对象 - * @throws 如果是真正的错误(非 ENOENT),则抛出异常 + * @throws 如果是真正的错误(非 ENOENT、非 SyntaxError),则抛出异常 */ export async function readChatIndex(projectDir: string): Promise { try { @@ -47,7 +70,16 @@ export async function readChatIndex(projectDir: string): Promise { return JSON.parse(content) as ChatIndex; } catch (error) { // 文件不存在是正常情况,返回空索引 - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + return {}; + } + // JSON 解析错误,返回空索引(文件可能损坏) + if (error instanceof SyntaxError) { return {}; } // 其他错误(权限问题、I/O 错误等)应该抛出 @@ -71,11 +103,7 @@ export async function saveSessionToIndex( const index = await readChatIndex(projectDir); index[name] = sessionId; - await fs.writeFile( - getIndexPath(projectDir), - JSON.stringify(index, null, 2), - 'utf-8', - ); + await atomicWrite(getIndexPath(projectDir), JSON.stringify(index, null, 2)); } /** @@ -95,11 +123,7 @@ export async function deleteSessionFromIndex( } delete index[name]; - await fs.writeFile( - getIndexPath(projectDir), - JSON.stringify(index, null, 2), - 'utf-8', - ); + await atomicWrite(getIndexPath(projectDir), JSON.stringify(index, null, 2)); return true; } From 369d8a934e64d1d9039c3f98098e30ffef6b0973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 07:11:08 +0800 Subject: [PATCH 08/13] fix(chat): address remaining review feedback - validation, confirmation, and code quality --- .../cli/src/ui/commands/chatCommand.test.ts | 37 +++++++- packages/cli/src/ui/commands/chatCommand.ts | 49 +++++++++-- packages/core/src/services/chatIndex.test.ts | 10 +-- packages/core/src/services/chatIndex.ts | 88 +++++++++++-------- 4 files changed, 132 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index e5f5a156914..f43fa4a1585 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -209,7 +209,7 @@ describe('chatCommand', () => { }); }); - it('should delete session by name', async () => { + it('should request confirmation before deleting session', async () => { const deleteCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'delete', ); @@ -218,6 +218,33 @@ describe('chatCommand', () => { 'test-session-1', ); + expect(getSessionIdByName).toHaveBeenCalledWith( + '/test/project/dir', + 'test-session-1', + ); + expect(result).toEqual({ + type: 'confirm_action', + prompt: + 'Are you sure you want to delete session "test-session-1"? This action cannot be undone.', + originalInvocation: { + raw: '/chat delete test-session-1', + }, + }); + }); + + it('should delete session after confirmation', async () => { + const confirmedContext = createMockContext({ + overwriteConfirmed: true, + }); + + const deleteCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'delete', + ); + const result = await deleteCommand?.action!( + confirmedContext, + 'test-session-1', + ); + expect(getSessionIdByName).toHaveBeenCalledWith( '/test/project/dir', 'test-session-1', @@ -240,14 +267,18 @@ describe('chatCommand', () => { ({ loadSession: vi.fn().mockResolvedValue({ messages: [] }), removeSession: vi.fn().mockResolvedValue(false), - }) as unknown as typeof SessionService, + }) as unknown as SessionService, ); + const confirmedContext = createMockContext({ + overwriteConfirmed: true, + }); + const deleteCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'delete', ); const result = await deleteCommand?.action!( - mockContext, + confirmedContext, 'test-session-1', ); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 4baa7607ccc..d31e830f355 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -19,6 +19,26 @@ import { SessionService, } from '@qwen-code/qwen-code-core'; +/** + * Validates a session name + * @param name The session name to validate + * @returns true if valid, or an error message string if invalid + */ +function validateSessionName(name: string): true | string { + if (!name) { + return 'Please provide a name. Usage: /chat '; + } + // Only allow letters, numbers, hyphens, underscores, and dots + if (!/^[a-zA-Z0-9_-./]+$/.test(name)) { + return 'Invalid session name. Use only letters, numbers, hyphens, underscores, and dots.'; + } + // Limit to 128 characters + if (name.length > 128) { + return 'Session name is too long. Maximum 128 characters.'; + } + return true; +} + export const chatCommand: SlashCommand = { name: 'chat', get description() { @@ -38,11 +58,12 @@ export const chatCommand: SlashCommand = { ): Promise => { const name = args.trim(); - if (!name) { + const validation = validateSessionName(name); + if (validation !== true) { return { type: 'message', messageType: 'error', - content: t('Please provide a name. Usage: /chat save '), + content: t(validation), }; } @@ -147,11 +168,12 @@ export const chatCommand: SlashCommand = { ): Promise => { const name = args.trim(); - if (!name) { + const validation = validateSessionName(name); + if (validation !== true) { return { type: 'message', messageType: 'error', - content: t('Please provide a name. Usage: /chat resume '), + content: t(validation), }; } @@ -236,14 +258,16 @@ export const chatCommand: SlashCommand = { ): Promise => { const name = args.trim(); - if (!name) { + const validation = validateSessionName(name); + if (validation !== true) { return { type: 'message', messageType: 'error', - content: t('Please provide a name. Usage: /chat delete '), + content: t(validation), }; } + // Check if session exists first try { const config = context.services.config; if (!config) { @@ -267,7 +291,18 @@ export const chatCommand: SlashCommand = { }; } - // Delete the actual session file (may not exist if manually deleted) + // Ask for confirmation before deleting + if (!context.overwriteConfirmed) { + return { + type: 'confirm_action', + prompt: `Are you sure you want to delete session "${name}"? This action cannot be undone.`, + originalInvocation: { + raw: context.invocation?.raw || `/chat delete ${name}`, + }, + }; + } + + // User confirmed deletion - delete the actual session file const sessionService = new SessionService(projectDir); const sessionDeleted = await sessionService.removeSession(sessionId); diff --git a/packages/core/src/services/chatIndex.test.ts b/packages/core/src/services/chatIndex.test.ts index b744269efcc..3b31611b144 100644 --- a/packages/core/src/services/chatIndex.test.ts +++ b/packages/core/src/services/chatIndex.test.ts @@ -9,13 +9,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; // Mock project directory -const mockProjectDir = vi.hoisted(() => { - const tempDir = process.env['TEMP'] || '/tmp'; - const pathSep = process.platform === 'win32' ? '\\' : '/'; - return { - path: `${tempDir}${pathSep}mock-project-test`, - }; -}); +const mockProjectDir = vi.hoisted(() => ({ + path: (process.env['TEMP'] || '/tmp') + '/mock-project-test', +})); import { saveSessionToIndex, diff --git a/packages/core/src/services/chatIndex.ts b/packages/core/src/services/chatIndex.ts index 583ec8f335a..8fa1050f879 100644 --- a/packages/core/src/services/chatIndex.ts +++ b/packages/core/src/services/chatIndex.ts @@ -10,17 +10,17 @@ import { QWEN_DIR } from '../config/storage.js'; import crypto from 'node:crypto'; /** - * 会话索引数据结构 - * 存储在 /.qwen/chat-index.json 中(按项目隔离) + * Session index data structure + * Stored in /.qwen/chat-index.json (isolated per project) */ export interface ChatIndex { - /** name -> sessionId 的映射 */ + /** name -> sessionId mapping */ [name: string]: string; } /** - * 获取索引文件路径 - * @param projectDir 项目目录路径 + * Gets the index file path + * @param projectDir The project directory path */ function getIndexPath(projectDir: string): string { const qwenDir = path.join(projectDir, QWEN_DIR); @@ -28,8 +28,8 @@ function getIndexPath(projectDir: string): string { } /** - * 确保项目 .qwen 目录存在 - * @param projectDir 项目目录路径 + * Ensures the project .qwen directory exists + * @param projectDir The project directory path */ async function ensureQwenDir(projectDir: string): Promise { const qwenDir = path.join(projectDir, QWEN_DIR); @@ -37,9 +37,9 @@ async function ensureQwenDir(projectDir: string): Promise { } /** - * 原子写入文件(使用临时文件 + rename) - * @param filePath 目标文件路径 - * @param content 文件内容 + * Atomically writes to a file (using temp file + rename) + * @param filePath Target file path + * @param content File content */ async function atomicWrite(filePath: string, content: string): Promise { const dir = path.dirname(filePath); @@ -48,28 +48,46 @@ async function atomicWrite(filePath: string, content: string): Promise { await fs.writeFile(tempFile, content, 'utf-8'); await fs.rename(tempFile, filePath); } catch (error) { - // 清理临时文件 + // Clean up temp file try { await fs.unlink(tempFile); } catch { - // 忽略清理错误 + // Ignore cleanup errors } throw error; } } /** - * 读取索引文件 - * @param projectDir 项目目录路径 - * @returns 索引对象,如果文件不存在则返回空对象 - * @throws 如果是真正的错误(非 ENOENT、非 SyntaxError),则抛出异常 + * Reads the chat index file + * @param projectDir The project directory path + * @returns Index object, returns empty object if file doesn't exist + * @throws On real errors (permissions, I/O failures) */ export async function readChatIndex(projectDir: string): Promise { try { const content = await fs.readFile(getIndexPath(projectDir), 'utf-8'); - return JSON.parse(content) as ChatIndex; + const parsed = JSON.parse(content); + + // Validate the parsed data + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error('Invalid chat index format'); + } + + // Ensure all values are strings + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== 'string') { + throw new Error(`Invalid entry in chat index: ${key}`); + } + } + + return parsed as ChatIndex; } catch (error) { - // 文件不存在是正常情况,返回空索引 + // File doesn't exist is normal, return empty index if ( typeof error === 'object' && error !== null && @@ -78,20 +96,20 @@ export async function readChatIndex(projectDir: string): Promise { ) { return {}; } - // JSON 解析错误,返回空索引(文件可能损坏) + // JSON parse error, return empty index (file may be corrupted) if (error instanceof SyntaxError) { return {}; } - // 其他错误(权限问题、I/O 错误等)应该抛出 + // Other errors (permissions, I/O) should throw throw error; } } /** - * 保存会话到索引 - * @param projectDir 项目目录路径 - * @param name 会话名称 - * @param sessionId 会话 ID + * Saves a session to the index + * @param projectDir The project directory path + * @param name Session name + * @param sessionId Session ID */ export async function saveSessionToIndex( projectDir: string, @@ -107,10 +125,10 @@ export async function saveSessionToIndex( } /** - * 从索引中删除会话 - * @param projectDir 项目目录路径 - * @param name 会话名称 - * @returns 是否删除成功 + * Deletes a session from the index + * @param projectDir The project directory path + * @param name Session name + * @returns Whether deletion was successful */ export async function deleteSessionFromIndex( projectDir: string, @@ -128,10 +146,10 @@ export async function deleteSessionFromIndex( } /** - * 根据名称获取会话 ID - * @param projectDir 项目目录路径 - * @param name 会话名称 - * @returns 会话 ID,如果不存在则返回 undefined + * Gets a session ID by name + * @param projectDir The project directory path + * @param name Session name + * @returns Session ID, or undefined if not found */ export async function getSessionIdByName( projectDir: string, @@ -142,9 +160,9 @@ export async function getSessionIdByName( } /** - * 列出所有已命名的会话 - * @param projectDir 项目目录路径 - * @returns 名称到 sessionId 的映射 + * Lists all named sessions + * @param projectDir The project directory path + * @returns Mapping of name to sessionId */ export async function listNamedSessions( projectDir: string, From a87fa3a3d7d2ebdf4d24e4dbaabdd8e257de5039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 07:22:49 +0800 Subject: [PATCH 09/13] refactor(chat): improve code quality based on local review --- packages/cli/src/i18n/locales/en.js | 10 +++++ packages/cli/src/i18n/locales/zh.js | 12 +++-- .../cli/src/ui/commands/chatCommand.test.ts | 45 +++++++++++++++++-- packages/cli/src/ui/commands/chatCommand.ts | 10 ++--- packages/core/src/services/chatIndex.ts | 9 ++-- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 7ef40def257..5517d3a2ada 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2030,4 +2030,14 @@ export default { 'Not in plan mode. Use "/plan" to enter plan mode first.', "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", + + // ============================================================================ + // Chat Session Commands + // ============================================================================ + 'chat.session_name_required': + 'Please provide a name. Usage: /chat ', + 'chat.invalid_session_name': + 'Invalid session name. Use only letters, numbers, hyphens, underscores, and dots.', + 'chat.session_name_too_long': + 'Session name is too long. Maximum 128 characters.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 50014ec5d4a..8bb1930d46c 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1829,9 +1829,15 @@ export default { 'Enabled plan mode. The agent will analyze and plan without executing tools.': '启用计划模式。智能体将只分析和规划,而不执行工具。', 'Already in plan mode. Use "/plan exit" to exit plan mode.': - '已处于计划模式。使用 "/plan exit" 退出计划模式。', + '已在计划模式中。使用 "/plan exit" 退出计划模式。', 'Not in plan mode. Use "/plan" to enter plan mode first.': - '未处于计划模式。请先使用 "/plan" 进入计划模式。', + '不在计划模式中。使用 "/plan" 进入计划模式。', - "Set up Qwen Code's status line UI": '配置 Qwen Code 的状态栏', + // ============================================================================ + // Chat Session Commands + // ============================================================================ + 'chat.session_name_required': '请提供名称。用法:/chat <命令> <名称>', + 'chat.invalid_session_name': + '无效的会话名称。只能使用字母、数字、连字符、下划线和点。', + 'chat.session_name_too_long': '会话名称太长。最多 128 个字符。', }; diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index f43fa4a1585..eb00e42018d 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -115,7 +115,46 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: expect.stringContaining('Please provide a name'), + content: 'chat.session_name_required', + }); + }); + + it('should return error for invalid session name with special characters', async () => { + const saveCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'save', + ); + + // Test various invalid names + const invalidNames = [ + 'my/session', // forward slash + 'my\\session', // backslash + 'my session', // space + 'my@session', // special char + 'my#session', // special char + ]; + + for (const invalidName of invalidNames) { + const result = await saveCommand?.action!(mockContext, invalidName); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'chat.invalid_session_name', + }); + } + }); + + it('should return error for session name exceeding max length', async () => { + const saveCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'save', + ); + const longName = 'a'.repeat(129); // 129 characters + + const result = await saveCommand?.action!(mockContext, longName); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'chat.session_name_too_long', }); }); @@ -168,7 +207,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: expect.stringContaining('Please provide a name'), + content: 'chat.session_name_required', }); }); @@ -205,7 +244,7 @@ describe('chatCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'error', - content: expect.stringContaining('Please provide a name'), + content: 'chat.session_name_required', }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index d31e830f355..fc2712b3ca9 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -22,19 +22,19 @@ import { /** * Validates a session name * @param name The session name to validate - * @returns true if valid, or an error message string if invalid + * @returns validation key for i18n, or true if valid */ function validateSessionName(name: string): true | string { if (!name) { - return 'Please provide a name. Usage: /chat '; + return 'chat.session_name_required'; } // Only allow letters, numbers, hyphens, underscores, and dots - if (!/^[a-zA-Z0-9_-./]+$/.test(name)) { - return 'Invalid session name. Use only letters, numbers, hyphens, underscores, and dots.'; + if (!/^[a-zA-Z0-9_.-]+$/.test(name)) { + return 'chat.invalid_session_name'; } // Limit to 128 characters if (name.length > 128) { - return 'Session name is too long. Maximum 128 characters.'; + return 'chat.session_name_too_long'; } return true; } diff --git a/packages/core/src/services/chatIndex.ts b/packages/core/src/services/chatIndex.ts index 8fa1050f879..bb0f05e340b 100644 --- a/packages/core/src/services/chatIndex.ts +++ b/packages/core/src/services/chatIndex.ts @@ -40,6 +40,7 @@ async function ensureQwenDir(projectDir: string): Promise { * Atomically writes to a file (using temp file + rename) * @param filePath Target file path * @param content File content + * @prerequisite The parent directory of filePath must exist */ async function atomicWrite(filePath: string, content: string): Promise { const dir = path.dirname(filePath); @@ -87,6 +88,10 @@ export async function readChatIndex(projectDir: string): Promise { return parsed as ChatIndex; } catch (error) { + // JSON parse error, return empty index (file may be corrupted) + if (error instanceof SyntaxError) { + return {}; + } // File doesn't exist is normal, return empty index if ( typeof error === 'object' && @@ -96,10 +101,6 @@ export async function readChatIndex(projectDir: string): Promise { ) { return {}; } - // JSON parse error, return empty index (file may be corrupted) - if (error instanceof SyntaxError) { - return {}; - } // Other errors (permissions, I/O) should throw throw error; } From 469d856657693bac08a5b2fc6ca5af38852a2dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 07:27:38 +0800 Subject: [PATCH 10/13] refactor(chat): address remaining review findings - i18n and documentation --- packages/cli/src/i18n/locales/en.js | 2 ++ packages/cli/src/i18n/locales/zh.js | 2 ++ packages/cli/src/ui/commands/chatCommand.test.ts | 2 ++ packages/cli/src/ui/commands/chatCommand.ts | 9 +++++++-- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5517d3a2ada..fffb4111b9c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2040,4 +2040,6 @@ export default { 'Invalid session name. Use only letters, numbers, hyphens, underscores, and dots.', 'chat.session_name_too_long': 'Session name is too long. Maximum 128 characters.', + 'Are you sure you want to delete session "{{name}}"? This action cannot be undone.': + 'Are you sure you want to delete session "{{name}}"? This action cannot be undone.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 8bb1930d46c..dcdcd31da94 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1840,4 +1840,6 @@ export default { 'chat.invalid_session_name': '无效的会话名称。只能使用字母、数字、连字符、下划线和点。', 'chat.session_name_too_long': '会话名称太长。最多 128 个字符。', + 'Are you sure you want to delete session "{{name}}"? This action cannot be undone.': + '确定要删除会话"{{name}}"吗?此操作无法撤销。', }; diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index eb00e42018d..5382f83a0b4 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -33,6 +33,8 @@ function createMockContext( } // Mock the chat index functions +// Note: i18n is not mocked, so t() returns the key itself. +// Tests assert on i18n keys rather than translated strings. vi.mock('@qwen-code/qwen-code-core', () => ({ saveSessionToIndex: vi.fn().mockResolvedValue(undefined), deleteSessionFromIndex: vi.fn().mockResolvedValue(true), diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index fc2712b3ca9..07851c125aa 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -20,7 +20,9 @@ import { } from '@qwen-code/qwen-code-core'; /** - * Validates a session name + * Validates a session name format (not existence). + * Note: This only checks the format of the name (allowed characters, length). + * Session existence is validated separately by getSessionIdByName(). * @param name The session name to validate * @returns validation key for i18n, or true if valid */ @@ -295,7 +297,10 @@ export const chatCommand: SlashCommand = { if (!context.overwriteConfirmed) { return { type: 'confirm_action', - prompt: `Are you sure you want to delete session "${name}"? This action cannot be undone.`, + prompt: t( + 'Are you sure you want to delete session "{{name}}"? This action cannot be undone.', + { name }, + ), originalInvocation: { raw: context.invocation?.raw || `/chat delete ${name}`, }, From 6e5af6444b430ee6a4ae8165c2febb3c7db2174a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 07:39:36 +0800 Subject: [PATCH 11/13] feat(chat): add documentation and harden validation with runtime robustness --- docs/users/features/commands.md | 44 +++++++++++++++++++ .../cli/src/ui/commands/chatCommand.test.ts | 2 + packages/cli/src/ui/commands/chatCommand.ts | 4 ++ packages/core/src/services/chatIndex.ts | 18 ++++++-- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index ce26c2962a7..3caf530b599 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -25,6 +25,50 @@ These commands help you save, restore, and summarize work progress. | `/compress` | Replace chat history with summary to save Tokens | `/compress` | | `/resume` | Resume a previous conversation session | `/resume` | | `/restore` | Restore files to state before tool execution | `/restore` (list) or `/restore ` | +| `/chat` | Save, list, resume, and delete named chat sessions | `/chat save `, `/chat list` | + +#### `/chat` Subcommands + +The `/chat` command provides session management through named aliases, stored in `/.qwen/chat-index.json` (isolated per project). + +| Subcommand | Description | Example | +| --------------------- | --------------------------------------------------- | ---------------------------- | +| `/chat save ` | Save the current session with a human-readable name | `/chat save auth-refactor` | +| `/chat list` | List all saved session names and their IDs | `/chat list` | +| `/chat resume ` | Restore a saved session by name | `/chat resume auth-refactor` | +| `/chat delete ` | Remove a saved session (requires confirmation) | `/chat delete auth-refactor` | + +**Session Name Rules:** + +- Only letters, numbers, hyphens (`-`), underscores (`_`), and dots (`.`) are allowed +- Maximum 128 characters +- Reserved names `.` and `..` are blocked + +**Example Workflow:** + +``` +# 1. Save your current work with a meaningful name +> /chat save feature-implementation + +# 2. List all saved sessions +> /chat list +Saved sessions: + +• feature-implementation (ID: abc12345...) +• bugfix-login (ID: def67890...) + +# 3. Switch to another task, then resume later +> /chat resume feature-implementation + +# 4. Clean up old sessions +> /chat delete bugfix-login +Are you sure you want to delete session "bugfix-login"? This action cannot be undone. +# (Confirm to proceed) +``` + +> [!note] +> +> Session names are project-scoped. The same name can refer to different sessions in different projects. Session data is stored separately from the index, so deleting a session from the index also removes its data file. ### 1.2 Interface and Workspace Control diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 5382f83a0b4..dfc6d8e49b0 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -133,6 +133,8 @@ describe('chatCommand', () => { 'my session', // space 'my@session', // special char 'my#session', // special char + '.', // reserved name + '..', // reserved name ]; for (const invalidName of invalidNames) { diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 07851c125aa..a8c63b93347 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -30,6 +30,10 @@ function validateSessionName(name: string): true | string { if (!name) { return 'chat.session_name_required'; } + // Block reserved names to prevent path traversal and index corruption + if (name === '.' || name === '..') { + return 'chat.invalid_session_name'; + } // Only allow letters, numbers, hyphens, underscores, and dots if (!/^[a-zA-Z0-9_.-]+$/.test(name)) { return 'chat.invalid_session_name'; diff --git a/packages/core/src/services/chatIndex.ts b/packages/core/src/services/chatIndex.ts index bb0f05e340b..e2915ac1a78 100644 --- a/packages/core/src/services/chatIndex.ts +++ b/packages/core/src/services/chatIndex.ts @@ -49,7 +49,18 @@ async function atomicWrite(filePath: string, content: string): Promise { await fs.writeFile(tempFile, content, 'utf-8'); await fs.rename(tempFile, filePath); } catch (error) { - // Clean up temp file + // Handle cross-device rename (EXDEV) - fallback to copy+delete + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EXDEV' + ) { + await fs.writeFile(filePath, content, 'utf-8'); + await fs.unlink(tempFile).catch(() => {}); + return; + } + // Clean up temp file and rethrow try { await fs.unlink(tempFile); } catch { @@ -80,9 +91,10 @@ export async function readChatIndex(projectDir: string): Promise { } // Ensure all values are strings - for (const [key, value] of Object.entries(parsed)) { + for (const [_key, value] of Object.entries(parsed)) { if (typeof value !== 'string') { - throw new Error(`Invalid entry in chat index: ${key}`); + // Gracefully degrade to empty index on malformed data + return {}; } } From b6bf94772f4e744ecf49c2c6dcfe1d3b03d812f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 07:58:10 +0800 Subject: [PATCH 12/13] feat(chat): add overwrite confirmation for /chat save command --- docs/users/features/commands.md | 17 ++++--- packages/cli/src/i18n/locales/en.js | 2 + packages/cli/src/i18n/locales/zh.js | 2 + .../cli/src/ui/commands/chatCommand.test.ts | 47 +++++++++++++++++++ packages/cli/src/ui/commands/chatCommand.ts | 15 ++++++ 5 files changed, 77 insertions(+), 6 deletions(-) diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 3caf530b599..a04b151c48f 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -31,12 +31,12 @@ These commands help you save, restore, and summarize work progress. The `/chat` command provides session management through named aliases, stored in `/.qwen/chat-index.json` (isolated per project). -| Subcommand | Description | Example | -| --------------------- | --------------------------------------------------- | ---------------------------- | -| `/chat save ` | Save the current session with a human-readable name | `/chat save auth-refactor` | -| `/chat list` | List all saved session names and their IDs | `/chat list` | -| `/chat resume ` | Restore a saved session by name | `/chat resume auth-refactor` | -| `/chat delete ` | Remove a saved session (requires confirmation) | `/chat delete auth-refactor` | +| Subcommand | Description | Example | +| --------------------- | ------------------------------------------------------------------------------------------ | ---------------------------- | +| `/chat save ` | Save the current session with a human-readable name (requires confirmation if name exists) | `/chat save auth-refactor` | +| `/chat list` | List all saved session names and their IDs | `/chat list` | +| `/chat resume ` | Restore a saved session by name | `/chat resume auth-refactor` | +| `/chat delete ` | Remove a saved session (requires confirmation) | `/chat delete auth-refactor` | **Session Name Rules:** @@ -50,6 +50,11 @@ The `/chat` command provides session management through named aliases, stored in # 1. Save your current work with a meaningful name > /chat save feature-implementation +# If the name already exists, you'll be asked to confirm: +> /chat save feature-implementation +Session "feature-implementation" already exists. Do you want to overwrite it? +# (Confirm to proceed) + # 2. List all saved sessions > /chat list Saved sessions: diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index fffb4111b9c..cd231172795 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2042,4 +2042,6 @@ export default { 'Session name is too long. Maximum 128 characters.', 'Are you sure you want to delete session "{{name}}"? This action cannot be undone.': 'Are you sure you want to delete session "{{name}}"? This action cannot be undone.', + 'Session "{{name}}" already exists. Do you want to overwrite it?': + 'Session "{{name}}" already exists. Do you want to overwrite it?', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index dcdcd31da94..46249c6cbf2 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1842,4 +1842,6 @@ export default { 'chat.session_name_too_long': '会话名称太长。最多 128 个字符。', 'Are you sure you want to delete session "{{name}}"? This action cannot be undone.': '确定要删除会话"{{name}}"吗?此操作无法撤销。', + 'Session "{{name}}" already exists. Do you want to overwrite it?': + '会话"{{name}}"已存在。确定要覆盖吗?', }; diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index dfc6d8e49b0..c39b1024369 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -179,6 +179,53 @@ describe('chatCommand', () => { content: expect.stringContaining('Session saved as'), }); }); + + it('should request confirmation when saving session with existing name', async () => { + const saveCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'save', + ); + + // Mock listNamedSessions to return an existing session with the same name + vi.mocked(listNamedSessions).mockResolvedValueOnce({ + 'my-test-session': 'old-session-id', + }); + + const result = await saveCommand?.action!(mockContext, 'my-test-session'); + + expect(result).toEqual({ + type: 'confirm_action', + prompt: + 'Session "my-test-session" already exists. Do you want to overwrite it?', + originalInvocation: { + raw: '/chat save my-test-session', + }, + }); + }); + + it('should save session after overwrite confirmation', async () => { + const confirmedContext = createMockContext({ + overwriteConfirmed: true, + }); + + const saveCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'save', + ); + const result = await saveCommand?.action!( + confirmedContext, + 'my-test-session', + ); + + expect(saveSessionToIndex).toHaveBeenCalledWith( + '/test/project/dir', + 'my-test-session', + 'current-session-id-12345', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Session saved as'), + }); + }); }); describe('list subcommand', () => { diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index a8c63b93347..dde198673b5 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -86,6 +86,21 @@ export const chatCommand: SlashCommand = { const projectDir = config.getTargetDir(); try { + // Check if session name already exists + const existingSessions = await listNamedSessions(projectDir); + if (name in existingSessions && !context.overwriteConfirmed) { + return { + type: 'confirm_action', + prompt: t( + 'Session "{{name}}" already exists. Do you want to overwrite it?', + { name }, + ), + originalInvocation: { + raw: context.invocation?.raw || `/chat save ${name}`, + }, + }; + } + await saveSessionToIndex(projectDir, name, sessionId); return { type: 'message', From 8074098d9cb9f9c62ecfdc27dd7ccb6ab5ad89dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B9=B4=E4=B8=80=E7=82=AD?= <15277912+lnxsun@user.noreply.gitee.com> Date: Sat, 11 Apr 2026 08:13:03 +0800 Subject: [PATCH 13/13] fix(chat): address critical security and performance issues --- docs/users/features/commands.md | 2 +- .../cli/src/ui/commands/chatCommand.test.ts | 63 ++++++++++++++++++- packages/cli/src/ui/commands/chatCommand.ts | 43 ++++++------- packages/core/src/services/chatIndex.ts | 40 +----------- 4 files changed, 84 insertions(+), 64 deletions(-) diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index a04b151c48f..b41fa81ccee 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -42,7 +42,7 @@ The `/chat` command provides session management through named aliases, stored in - Only letters, numbers, hyphens (`-`), underscores (`_`), and dots (`.`) are allowed - Maximum 128 characters -- Reserved names `.` and `..` are blocked +- Reserved names `.`, `..`, `__proto__`, `constructor`, and `prototype` are blocked **Example Workflow:** diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index c39b1024369..ec928ac6669 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -46,6 +46,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ SessionService: vi.fn().mockImplementation(() => ({ loadSession: vi.fn().mockResolvedValue({ messages: [] }), removeSession: vi.fn().mockResolvedValue(true), + sessionExists: vi.fn().mockResolvedValue(true), })), })); @@ -135,6 +136,9 @@ describe('chatCommand', () => { 'my#session', // special char '.', // reserved name '..', // reserved name + '__proto__', // prototype pollution + 'constructor', // prototype pollution + 'prototype', // prototype pollution ]; for (const invalidName of invalidNames) { @@ -263,6 +267,16 @@ describe('chatCommand', () => { }); it('should resume session by name and return dialog action', async () => { + const { SessionService } = await import('@qwen-code/qwen-code-core'); + vi.mocked(SessionService).mockImplementationOnce( + () => + ({ + loadSession: vi.fn().mockResolvedValue({ messages: [] }), + removeSession: vi.fn().mockResolvedValue(true), + sessionExists: vi.fn().mockResolvedValue(true), + }) as unknown as SessionService, + ); + const resumeCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'resume', ); @@ -339,6 +353,7 @@ describe('chatCommand', () => { '/test/project/dir', 'test-session-1', ); + expect(listNamedSessions).toHaveBeenCalledWith('/test/project/dir'); expect(deleteSessionFromIndex).toHaveBeenCalledWith( '/test/project/dir', 'test-session-1', @@ -350,20 +365,61 @@ describe('chatCommand', () => { }); }); - it('should warn when session file not found but removed from index', async () => { + it('should not delete session file if other names reference it', async () => { + const confirmedContext = createMockContext({ + overwriteConfirmed: true, + }); + + // Mock listNamedSessions to return another name pointing to the same session + vi.mocked(listNamedSessions).mockResolvedValueOnce({ + 'test-session-1': 'test-session-id-12345', + 'backup-session': 'test-session-id-12345', // Same session ID + }); + const { SessionService } = await import('@qwen-code/qwen-code-core'); + const mockRemoveSession = vi.fn().mockResolvedValue(true); vi.mocked(SessionService).mockImplementationOnce( () => ({ loadSession: vi.fn().mockResolvedValue({ messages: [] }), - removeSession: vi.fn().mockResolvedValue(false), + removeSession: mockRemoveSession, }) as unknown as SessionService, ); + const deleteCommand = chatCommand.subCommands?.find( + (cmd) => cmd.name === 'delete', + ); + await deleteCommand?.action!(confirmedContext, 'test-session-1'); + + // Session file should NOT be deleted because another name references it + expect(mockRemoveSession).not.toHaveBeenCalled(); + // But the name should still be removed from the index + expect(deleteSessionFromIndex).toHaveBeenCalledWith( + '/test/project/dir', + 'test-session-1', + ); + }); + + it('should warn when session file not found but removed from index', async () => { const confirmedContext = createMockContext({ overwriteConfirmed: true, }); + // Mock listNamedSessions to return only this session (no other refs) + vi.mocked(listNamedSessions).mockResolvedValueOnce({ + 'test-session-1': 'test-session-id-12345', + }); + + const { SessionService } = await import('@qwen-code/qwen-code-core'); + vi.mocked(SessionService).mockImplementationOnce( + () => + ({ + loadSession: vi.fn().mockResolvedValue({ messages: [] }), + removeSession: vi.fn().mockResolvedValue(false), + sessionExists: vi.fn().mockResolvedValue(true), + }) as unknown as SessionService, + ); + const deleteCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === 'delete', ); @@ -372,10 +428,11 @@ describe('chatCommand', () => { 'test-session-1', ); + // Since session file was not removed but index was, show info message expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining('removed from index'), + content: expect.stringContaining('deleted'), }); }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index dde198673b5..9c19917cf4d 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -34,6 +34,10 @@ function validateSessionName(name: string): true | string { if (name === '.' || name === '..') { return 'chat.invalid_session_name'; } + // Block prototype-polluting names + if (name === '__proto__' || name === 'constructor' || name === 'prototype') { + return 'chat.invalid_session_name'; + } // Only allow letters, numbers, hyphens, underscores, and dots if (!/^[a-zA-Z0-9_.-]+$/.test(name)) { return 'chat.invalid_session_name'; @@ -219,27 +223,11 @@ export const chatCommand: SlashCommand = { }; } - // Verify session data exists + // Verify session file exists (lightweight check - reads only first line) const sessionService = new SessionService(projectDir); + const exists = await sessionService.sessionExists(sessionId); - let sessionData; - try { - sessionData = await sessionService.loadSession(sessionId); - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: t( - 'Failed to load session data for "{{name}}": {{error}}', - { - name, - error: error instanceof Error ? error.message : String(error), - }, - ), - }; - } - - if (!sessionData) { + if (!exists) { return { type: 'message', messageType: 'error', @@ -326,11 +314,20 @@ export const chatCommand: SlashCommand = { }; } - // User confirmed deletion - delete the actual session file - const sessionService = new SessionService(projectDir); - const sessionDeleted = await sessionService.removeSession(sessionId); + // User confirmed deletion - check if other names reference this session + const allSessions = await listNamedSessions(projectDir); + const otherRefs = Object.entries(allSessions).filter( + ([n, id]) => n !== name && id === sessionId, + ); + + // Only remove the session file if no other name references it + let sessionDeleted = false; + if (otherRefs.length === 0) { + const sessionService = new SessionService(projectDir); + sessionDeleted = await sessionService.removeSession(sessionId); + } - // Always remove from the index + // Always remove the specific name from the index const indexDeleted = await deleteSessionFromIndex(projectDir, name); if (!indexDeleted) { diff --git a/packages/core/src/services/chatIndex.ts b/packages/core/src/services/chatIndex.ts index e2915ac1a78..2a1da3d4920 100644 --- a/packages/core/src/services/chatIndex.ts +++ b/packages/core/src/services/chatIndex.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import { QWEN_DIR } from '../config/storage.js'; -import crypto from 'node:crypto'; +import { atomicWriteJSON } from '../utils/atomicFileWrite.js'; /** * Session index data structure @@ -36,40 +36,6 @@ async function ensureQwenDir(projectDir: string): Promise { await fs.mkdir(qwenDir, { recursive: true }); } -/** - * Atomically writes to a file (using temp file + rename) - * @param filePath Target file path - * @param content File content - * @prerequisite The parent directory of filePath must exist - */ -async function atomicWrite(filePath: string, content: string): Promise { - const dir = path.dirname(filePath); - const tempFile = path.join(dir, `.tmp-${crypto.randomUUID()}`); - try { - await fs.writeFile(tempFile, content, 'utf-8'); - await fs.rename(tempFile, filePath); - } catch (error) { - // Handle cross-device rename (EXDEV) - fallback to copy+delete - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === 'EXDEV' - ) { - await fs.writeFile(filePath, content, 'utf-8'); - await fs.unlink(tempFile).catch(() => {}); - return; - } - // Clean up temp file and rethrow - try { - await fs.unlink(tempFile); - } catch { - // Ignore cleanup errors - } - throw error; - } -} - /** * Reads the chat index file * @param projectDir The project directory path @@ -134,7 +100,7 @@ export async function saveSessionToIndex( const index = await readChatIndex(projectDir); index[name] = sessionId; - await atomicWrite(getIndexPath(projectDir), JSON.stringify(index, null, 2)); + await atomicWriteJSON(getIndexPath(projectDir), index); } /** @@ -154,7 +120,7 @@ export async function deleteSessionFromIndex( } delete index[name]; - await atomicWrite(getIndexPath(projectDir), JSON.stringify(index, null, 2)); + await atomicWriteJSON(getIndexPath(projectDir), index); return true; }