Skip to content

feat: add /chat command for saving, listing, resuming, and deleting named sessions#3105

Closed
lnxsun wants to merge 13 commits into
QwenLM:mainfrom
lnxsun:feat/chat-session-command
Closed

feat: add /chat command for saving, listing, resuming, and deleting named sessions#3105
lnxsun wants to merge 13 commits into
QwenLM:mainfrom
lnxsun:feat/chat-session-command

Conversation

@lnxsun

@lnxsun lnxsun commented Apr 10, 2026

Copy link
Copy Markdown

目标:
我想给qwen code开发一个/chat命令,用其来保存、查看、恢复和删除会话。
实现的 /chat 子命令:**

  • /chat save [name] - 给当前会话命名保存
  • /chat list - 列出已命名保存的会话
  • /chat resume [name] - 按名字恢复会话
  • /chat delete [name] - 删除已命名的会话
    Related to iflow cli的好功能接过来 #3025

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

我已经在本地重新构建了cli.js并逐一测试了/chat的4个子命令(save、list、resume、delete)的功能,对于一开始存在的resume不能跳转到原save的会话的问题也进行了修正和复测通过,确认都能实现预期功能,请测试并拉取

Comment thread packages/core/src/services/chatIndex.ts Outdated
* 读取索引文件
* @returns 索引对象,如果文件不存在则返回空对象
*/
export async function readChatIndex(): Promise<ChatIndex> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] readChatIndex() currently turns every read/parse failure into {}. That means permission errors and other I/O failures are silently reported to callers as "no saved sessions" / "session not found", which makes the new /chat commands lose data visibility without any actionable error.

Suggested change
export async function readChatIndex(): Promise<ChatIndex> {
export async function readChatIndex(): Promise<ChatIndex> {
try {
const content = await fs.readFile(getIndexPath(), 'utf-8');
return JSON.parse(content) as ChatIndex;
} catch (error) {
if (error instanceof SyntaxError) {
return {};
}
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
) {
return {};
}
throw error;
}
}

— gpt-5.4 via Qwen Code /review

Comment thread packages/core/src/services/chatIndex.ts Outdated
/**
* 获取索引文件路径
*/
function getIndexPath(): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This index is written to a single global ~/.qwen/chat-index.json, but session lookup/removal is project-scoped through SessionService(cwd). In practice that means /chat list can show names saved in other repos that /chat resume cannot load here, and the index also ignores custom runtime roots such as QWEN_RUNTIME_DIR.

Suggested change
function getIndexPath(): string {
function getIndexPath(): string {
return path.join(new Storage(process.cwd()).getProjectDir(), 'chat-index.json');
}

The exact storage helper may differ, but the index needs to follow the same project/runtime scoping rules as session storage.

— gpt-5.4 via Qwen Code /review

if (!deleted) {
return {
type: 'message',
messageType: 'error',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] /chat delete <name> only removes the alias from chat-index.json; it never deletes the underlying session file. That makes the command behave like "unlink alias" rather than "delete session", which is surprising for users and leaves conversation data on disk.

Suggested change
messageType: 'error',
const sessionId = await getSessionIdByName(name);
if (!sessionId) {
return {
type: 'message',
messageType: 'error',
content: t('Session "{{name}}" not found.', { name }),
};
}
const config = context.services.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const sessionService = new SessionService(config.getTargetDir());
const removed = await sessionService.removeSession(sessionId);
if (!removed) {
return {
type: 'message',
messageType: 'error',
content: t('Session "{{name}}" not found.', { name }),
};
}
await deleteSessionFromIndex(name);

— gpt-5.4 via Qwen Code /review

'test-session-1',
);

expect(result).toEqual({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] This test is still asserting the old resume behavior, but the implementation now returns a dialog: 'resume' action and requires a valid CommandContext. The focused test run is currently red because this case calls resumeCommand.action(undefined as any, ...) and then expects an info message containing Found session.

Suggested change
expect(result).toEqual({
const mockContext: CommandContext = {
services: {
config: {
getTargetDir: () => '/tmp/project',
},
},
ui: {} as never,
executionMode: 'non_interactive',
};
const result = await resumeCommand?.action!(mockContext, 'test-session-1');
expect(result).toEqual({
type: 'dialog',
dialog: 'resume',
params: { sessionId: 'test-session-id-12345' },
});

— gpt-5.4 via Qwen Code /review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

感谢@wenshao 的review,我修改您提出的问题

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

wenshao 已经针对您提出的4个问题重新完善并测试通过

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note: packages/cli/src/ui/contexts/UIActionsContext.tsx:97 declares openResumeDialog: () => void but useResumeCommand.ts and slashCommandProcessor.ts both use (sessionId?: string) => void. Please update the interface to match: openResumeDialog: (sessionId?: string) => void;


// Mock project directory
const mockProjectDir = vi.hoisted(() => ({
path: path.join(process.env['TEMP'] || '/tmp', 'mock-project-test'),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] vi.hoisted() executes before module imports, so path.join(...) fails because path is not yet defined. This causes the entire test suite to fail at startup — zero tests actually run.

Suggested change
path: path.join(process.env['TEMP'] || '/tmp', 'mock-project-test'),
const mockProjectDir = vi.hoisted(() => ({
path: (process.env['TEMP'] || '/tmp') + '/mock-project-test',
}));

— qwen3.6-plus via Qwen Code /review

const { SessionService } = await import('@qwen-code/qwen-code-core');
vi.mocked(SessionService).mockImplementationOnce(
() =>
({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] as unknown as typeof SessionService casts to the constructor type, but mockImplementationOnce should return an instance shape. This causes a TypeScript error (TS2740).

Suggested change
({
}) as unknown as SessionService,

— qwen3.6-plus via Qwen Code /review

return t('Save the current session with a name.');
},
kind: CommandKind.BUILT_IN,
action: async (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Session names from args.trim() are used directly with no validation. Names containing /, \, null bytes, or excessively long strings could corrupt the index JSON or cause unexpected filesystem behavior.

Suggested change
action: async (
const name = args.trim();
if (!name) {
return {
type: 'message',
messageType: 'error',
content: t('Please provide a name. Usage: /chat save <name>'),
};
}
if (!/^[a-zA-Z0-9_\-\.]+$/.test(name) || name.length > 128) {
return {
type: 'message',
messageType: 'error',
content: t('Invalid session name. Use only letters, numbers, hyphens, underscores, and dots.'),
};
}

— qwen3.6-plus via Qwen Code /review

}
},
},
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The delete command immediately removes the session file and index entry without any user confirmation. A typo like /chat delete important-session causes irreversible data loss. Other destructive operations in the codebase use confirmation dialogs.

Consider returning a confirm_action result before proceeding with deletion, matching the pattern used by other destructive commands.

— qwen3.6-plus via Qwen Code /review

Comment thread packages/core/src/services/chatIndex.ts Outdated
import { QWEN_DIR } from '../config/storage.js';

/**
* 会话索引数据结构

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] All JSDoc comments in this file are in Chinese (e.g., /** 会话索引数据结构 */), while the rest of the codebase uses English exclusively. Please translate to match project conventions.

— qwen3.6-plus via Qwen Code /review

Comment thread packages/core/src/services/chatIndex.ts Outdated
export async function readChatIndex(projectDir: string): Promise<ChatIndex> {
try {
const content = await fs.readFile(getIndexPath(projectDir), 'utf-8');
return JSON.parse(content) as ChatIndex;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] JSON.parse(content) as ChatIndex assumes the result is Record<string, string>. If the file is corrupted (e.g., { "foo": 123 }), callers like listNamedSessions will crash on sessions[name].substring(0, 8).

Suggested change
return JSON.parse(content) as ChatIndex;
export async function readChatIndex(projectDir: string): Promise<ChatIndex> {
try {
const content = await fs.readFile(getIndexPath(projectDir), 'utf-8');
const parsed = JSON.parse(content);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Invalid chat index format');
}
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) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
throw error;
}
}

— qwen3.6-plus via Qwen Code /review


const openResumeDialog = useCallback(
(sessionId?: string) => {
if (sessionId) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] handleResume(sessionId) is async but called without await or .catch(). If loadSession throws (not returns undefined), it becomes an unhandled promise rejection.

Suggested change
if (sessionId) {
if (sessionId) {
handleResume(sessionId).catch((err) => {
// Log or handle error appropriately
});

— qwen3.6-plus via Qwen Code /review

}
}

/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] saveSessionToIndex and deleteSessionFromIndex perform a read-modify-write pattern without any file locking or atomicity. Concurrent saves could lose data. At minimum, document the concurrency assumption. For robustness, write to a temp file and fs.rename atomically.

— qwen3.6-plus via Qwen Code /review

expect(result.args).toBe('my-session');
expect(result.canonicalPath).toEqual(['chat', 'resume']);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Tests only check return messages but never verify that mocked functions (saveSessionToIndex, deleteSessionFromIndex, etc.) were called with correct arguments. Add expect(saveSessionToIndex).toHaveBeenCalledWith(...) assertions to ensure the command actually interacts with its dependencies.

— qwen3.6-plus via Qwen Code /review

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

谢谢 rewiew,我来修改

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

已修复 `openResumeDialog` 类型签名不匹配的问题:

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

已修复所有 review 提出的问题:

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

第二轮修复 - 已完成并推送 ✅

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

第三轮改进 - 基于本地 Review ✅

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

第四轮改进 - 本地 Review 后续修复 ✅

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

第五轮改进 - 文档 + 防御性编程 ✅

// Limit to 128 characters
if (name.length > 128) {
return 'chat.session_name_too_long';
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] __proto__ passes the validation regex /^[a-zA-Z0-9_.-]+$/ but silently corrupts the chat index. When saveSessionToIndex executes index['__proto__'] = sessionId, it sets the prototype instead of creating an own property — Object.keys() returns [] and JSON.stringify() produces {}, losing all saved session mappings.

Suggested change
}
// Only allow letters, numbers, hyphens, underscores, and dots
// Reject prototype-polluting names
if (!/^[a-zA-Z0-9_.-]+$/.test(name) || name === '__proto__' || name === 'constructor' || name === 'prototype') {
return 'chat.invalid_session_name';
}

Alternatively, use Object.create(null) for the index object in chatIndex.ts so that __proto__ is treated as a normal key.

— glm-5.1 via Qwen Code /review

}

// User confirmed deletion - delete the actual session file
const sessionService = new SessionService(projectDir);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] /chat delete removes the underlying session file via sessionService.removeSession(sessionId) without checking if other saved names reference the same session ID. The same session can be saved under multiple names (e.g., /chat save draft then /chat save backup). Deleting one name removes the file on disk, orphaning all other names that still point to it.

Suggested change
const sessionService = new SessionService(projectDir);
// Only remove the session file if no other name references it
const allSessions = await listNamedSessions(projectDir);
const otherRefs = Object.entries(allSessions)
.filter(([n, id]) => n !== name && id === sessionId);
if (otherRefs.length === 0) {
const sessionService = new SessionService(projectDir);
await sessionService.removeSession(sessionId);
}
// Always remove the specific name from the index
const indexDeleted = await deleteSessionFromIndex(projectDir, name);

— glm-5.1 via Qwen Code /review

Comment thread packages/core/src/services/chatIndex.ts Outdated
* @param content File content
* @prerequisite The parent directory of filePath must exist
*/
async function atomicWrite(filePath: string, content: string): Promise<void> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The local atomicWrite duplicates the existing atomicWriteJSON utility from packages/core/src/utils/atomicFileWrite.ts. The existing utility also has exponential backoff retries for Windows EPERM/EACCES errors, which this implementation lacks.

Suggested change
async function atomicWrite(filePath: string, content: string): Promise<void> {
import { atomicWriteJSON } from '../utils/atomicFileWrite.js';
// Replace:
// await atomicWrite(getIndexPath(projectDir), JSON.stringify(index, null, 2));
// With:
// await atomicWriteJSON(getIndexPath(projectDir), index);
// Then remove the local atomicWrite function and the crypto import.

— glm-5.1 via Qwen Code /review


let sessionData;
try {
sessionData = await sessionService.loadSession(sessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] sessionService.loadSession(sessionId) reads and parses the entire JSONL file to verify existence, then handleResume in useResumeCommand.ts calls it again — doubling I/O cost. SessionService exposes a lighter sessionExists method that reads only the first line.

Suggested change
sessionData = await sessionService.loadSession(sessionId);
const sessionService = new SessionService(projectDir);
const exists = await sessionService.sessionExists(sessionId);
if (!exists) {
return {
type: 'message',
messageType: 'error',
content: t(
'Session data for "{{name}}" could not be loaded. The session file may have been deleted.',
{ name },
),
};
}

— glm-5.1 via Qwen Code /review

@lnxsun

lnxsun commented Apr 10, 2026

Copy link
Copy Markdown
Author

第六轮改进 - 本地试用发现问题并修复 ✅

@lnxsun

lnxsun commented Apr 11, 2026

Copy link
Copy Markdown
Author

第七轮改进 - 修复 Critical 安全和性能问题 ✅

@pomelo-nwu

Copy link
Copy Markdown
Collaborator

@lnxsun @wenshao I think we should consider the design of the previous PR: #1113. The /chat command has been deprecated. In the new design, we propose using /resume and /delete to allow users to select a session from the auto-saved session list for restoration or deletion.

@lnxsun

lnxsun commented Apr 11, 2026

Copy link
Copy Markdown
Author

感谢@pomelo-nwu 指出这一点!我注意到 PR #1113 已经移除了 /chat 命令,转而采用 --continue/--resume 参数。
不过,本 PR 新增了一些 PR #1113 不具备的功能:

  1. 命名会话—— 用户可以用有意义的名称(而非仅用 ID)标记重要会话
  2. 会话确认—— 防止意外覆盖 / 删除会话
  3. 项目级作用域隔离—— 每个项目拥有独立的会话命名空间
    这是我第一个PR,之前不知道PR #1113的存在,只是觉得iflow cli的/chat功能很好用,现在iflow cli要在4.17关闭,觉得/chat 功能很实用,避免的随机生成的会话历史命名随机不好查找且不能重命名,在@yiliang114
    的鼓励下开始了第一个PR,在@wenshao 的鞭策下不断的完善
    那么后续我们应当如何处理?还是说两种方案有共存的空间,继续保留PR feat: add /chat command for saving, listing, resuming, and deleting named sessions #3105

@tanzhenxin

Copy link
Copy Markdown
Collaborator

@lnxsun 感谢你的贡献!

关于会话历史管理,我们现有默认的会话级自动保存,以及 /resume 做恢复(类似于 /chat save/chat list),然后在 #3093 中我们会处理 rename、delete 这些行为。这一套交互是跟 Claude Code 类似的。

我们不希望对于同样的行为保留两套交互,这样会对用户造成困扰。所以会关闭掉这个 PR,欢迎继续关注和参与其他 issue!

@tanzhenxin tanzhenxin closed this Apr 11, 2026
@lnxsun

lnxsun commented Apr 11, 2026

Copy link
Copy Markdown
Author

@lnxsun 感谢你的贡献!

关于会话历史管理,我们现有默认的会话级自动保存,以及 /resume 做恢复(类似于 /chat save/chat list),然后在 #3093 中我们会处理 rename、delete 这些行为。这一套交互是跟 Claude Code 类似的。

我们不希望对于同样的行为保留两套交互,这样会对用户造成困扰。所以会关闭掉这个 PR,欢迎继续关注和参与其他 issue!

@tanzhenxin 感谢您的解释,加上 #3093 中的rename、delete相当于实现了/chat的功能

@qqqys

qqqys commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

感谢你的贡献!我最近参考 Claude Code 的设计做了一些 session 方面的能力对齐。

你这个 PR 里有几个地方我也做了采纳:

  1. 名称安全校验 — 防原型污染(proto、constructor)和路径穿越的校验很细致,这块之前没考虑到,已经采纳了类似的思路
  2. core/command 分层 — chatIndex 只做 name→sessionId 的索引读写,command 层处理交互逻辑,职责划分清晰,和SessionService/ChatRecordingService 的分层思路一致

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants