mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 04:00:36 +00:00
feat(session): add rename, delete, and auto-title generation for session (#3093)
* feat(session): add rename, delete, and auto-title generation for sessions - Add /rename command with LLM auto-title generation when no args provided - Add /delete command to remove sessions from the session picker - Display session name tag embedded in input prompt top border - Restore session name on /resume and --resume <title> CLI flag - Support rename and delete via ACP extMethod for VSCode extension - Add rename/delete UI to WebUI SessionSelector with two-click delete confirmation - Fix parentUuid chain: custom_title records now correctly reference the previous record's UUID, preventing session history from appearing empty after rename - Add SESSION_FILE_PATTERN validation to all SessionService methods that construct file paths from sessionId (defense-in-depth against path traversal) - Fix fd leak in readCustomTitleFromFile with try/finally - Fix --resume <title> exit code (exit 1 when no match found) - Add project ownership checks to VSCode qwenSessionReader delete/rename Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(session): fix broken imports and missing mocks from rename/auto-title feature - Fix renameCommand.ts import path to use barrel export instead of deep path - Add setSessionName to mock CommandContext - Add getSessionTitle to SessionService mock in useResumeCommand tests - Update renameCommand tests for auto-generate title behavior - Update InputPrompt snapshots Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(session): add head+tail dual-read, string-level extraction, and finalize mechanism - Add sessionStorageUtils with extractLastJsonStringField() for fast string-level JSON field extraction without full parse - Add readHeadAndTailSync() to read first and last 64KB of session files - Replace readCustomTitleFromFile() with readSessionTitleFromFile() using head+tail dual-read (tail customTitle > head customTitle) - Add finalize() to ChatRecordingService as single entry point for re-appending session metadata on any session departure - Call finalize() on resume, session switch, and shutdown - Export sessionStorageUtils from core package Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): show filtered picker when /resume <title> matches multiple sessions Previously, multiple title matches opened the full session picker, forcing the user to re-find their session. Now the matched sessions are passed through as initialSessions to the picker, skipping the full listSessions() load and showing only the relevant results. Also clears sessionName on /clear so new sessions don't carry stale title tags from the previous session. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ui): use stringWidth for CJK-safe border alignment in input prompt topRightLabel.length counts UTF-16 code units, not terminal columns. CJK characters take 2 columns but .length returns 1, causing the border line to overflow. Use string-width for correct display width. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(session): address remaining PR #3093 review feedback - Add SESSION_TITLE_MAX_LENGTH shared constant in core, replace hardcoded 200 in CLI/ACP/VSCode/WebUI - Add title length validation to ACP renameSession endpoint - Make recordCustomTitle return boolean; renameCommand checks it before updating UI to prevent silent data loss - Add gitBranch to VSCode rename record for consistency with CLI - Remove misleading "enforce kebab-case" comment - Remove duplicate JSDoc on topRightLabel Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(ui): add animated dots to session name generation loading indicator The static "Generating session name…" text gave no visual feedback that the operation was in progress. Cycle through ".", "..", "..." every 500ms so users can tell the LLM call is still running. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * feat(cli): add /tag as alias for /rename command Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * feat(vscode): add loading overlay when switching to historical conversations Adds isSwitchingSession state and sessionLoadComplete message to show a loading transition while session history is being rehydrated via ACP. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(vscode): add 15s timeout fallback for session switching loading state Prevents loading overlay from getting stuck indefinitely if sessionLoadComplete message is never received. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(core): fix extractLastJsonStringField offset tracking and add lineContains filter 1. Track global character offset across both pattern variants so the truly last match wins (previously the second pattern scan could overwrite a later match from the first pattern). 2. Add optional lineContains parameter to scope matches to lines containing a marker (e.g. "custom_title"), preventing false matches from user content that happens to include a "customTitle" field. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * chore(cli): add i18n import to DialogManager Co-Authored-By: Qwen-Coder <noreply@qwen.ai> * fix(vscode): align currentConversationId with webview on fallback restore When session/load falls back to creating a fresh ACP session, backend was tracking the new ACP id while the webview still viewed the archived sessionId. That desync caused delete/rename/title-update to target the wrong session during the fallback window, and prevented the post-first- message sync path from firing because the two ids were pre-aligned. Keep currentConversationId pointing at the archived sessionId until the existing stream-end sync flips both sides to the live ACP id on the first user message. Matches the pattern already used by the offline branch. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(core): exhaustive scan in findSessionsByTitle to avoid mtime-boundary misses listSessions() paginates with an mtime-only cursor and strict `<` filter. When several session files share the same mtime across a page boundary, the next page's filter drops them, so --resume <title> could silently miss valid matches. Scan all session files directly for title lookup, with filename as a stable tie-breaker. Also check the (cheap) custom title before the full hydration pass (first-record read, project filter, message count, prompt extraction) so non-matching sessions skip the extra I/O. listSessions() itself is left alone: its cursor crosses ACP/webview package boundaries as a number and this edge case only affects UI display order, not data loss. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(acp): plumb listSessions page size through _meta VSCode companion passes `size` to acpConnection.listSessions, but the ACP spec's ListSessionsRequest schema has no `size` field, so the SDK's zod validator strips it before the agent handler sees it. The agent then only forwarded `cursor` to SessionService.listSessions, silently ignoring the caller's page-size intent. Carry page size through `_meta.size` on both sides, matching the pattern already used for other Qwen Code ACP extensions (e.g. the filesystem service's `_meta.bom` / `_meta.encoding`). `_meta` is typed as an open record in the ACP schema, so extra keys survive validation. Co-Authored-By: Qwen-Coder <noreply@qwen.com> * fix(webui): avoid unintended rename when canceling with Escape The rename input auto-submits on blur, and pressing Escape also triggers blur (via setRenamingSessionId(null) unmounting the input). Because state updates are async, the blur handler's handleRenameSubmit could still read the pre-Escape renameValue from its closure and call onRenameSession, turning a cancel into an accidental rename. Track cancellation via an isCancelingRenameRef flag: set it in the Escape branch, and have onBlur short-circuit when the flag is true, then reset it. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(vscode-ide-companion): clear switch timeout on unmount The 15s session-switch fallback timer was only cleared on the next call to setIsSwitchingSession. If the webview is torn down mid-switch, the timer stays alive and later fires setIsSwitchingSessionRaw(false) on an unmounted hook. Add a useEffect cleanup to clear any pending timer on unmount. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(cli): break handleResume/slashCommandActions circular dep slashCommandActions (useMemo) depends on handleResume, but handleResume was declared after useSlashCommandProcessor so it could call setAwayRecapItem(null). useSlashCommandProcessor itself consumes slashCommandActions, closing a three-way cycle that tsc catches as TS2448 "used before declaration" once #3478's AppContainer changes land in main and get auto-merged into open PRs. Move handleResume above slashCommandActions and route the recap clear through a ref that a later useEffect syncs with setAwayRecapItem. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> * fix(core): scan full file when title is not in tail window Replace the head+tail dual-read with readLastJsonStringFieldSync: scan the tail first and return on hit, otherwise stream the whole file and return the last match. Closes the blind spot where a custom_title record landing between the head and tail windows would be missed on large session files. Co-Authored-By: Qwen-Coder <noreply@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <noreply@qwen.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Qwen-Coder <noreply@qwen.ai> Co-authored-by: Qwen-Coder <noreply@alibabacloud.com>
This commit is contained in:
parent
d1c8dff4d2
commit
0c423deedf
47 changed files with 3275 additions and 204 deletions
162
packages/cli/src/ui/commands/renameCommand.test.ts
Normal file
162
packages/cli/src/ui/commands/renameCommand.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renameCommand } from './renameCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('renameCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(renameCommand.name).toBe('rename');
|
||||
expect(renameCommand.description).toBe('Rename the current conversation');
|
||||
});
|
||||
|
||||
it('should return error when config is not available', async () => {
|
||||
mockContext.services.config = null;
|
||||
|
||||
const result = await renameCommand.action!(mockContext, 'my-feature');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config is not available.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when no name is provided and auto-generate fails', async () => {
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getSessionService: vi.fn().mockReturnValue({
|
||||
renameSession: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
getModel: vi.fn(),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not generate a title. Usage: /rename <name>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when only whitespace is provided and auto-generate fails', async () => {
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getSessionService: vi.fn().mockReturnValue({
|
||||
renameSession: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
getModel: vi.fn(),
|
||||
};
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, ' ');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not generate a title. Usage: /rename <name>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should rename via ChatRecordingService when available', async () => {
|
||||
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordCustomTitle: mockRecordCustomTitle,
|
||||
}),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getSessionService: vi.fn().mockReturnValue({
|
||||
renameSession: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, 'my-feature');
|
||||
|
||||
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Session renamed to "my-feature"',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to SessionService when ChatRecordingService is unavailable', async () => {
|
||||
const mockRenameSession = vi.fn().mockResolvedValue(true);
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getSessionService: vi.fn().mockReturnValue({
|
||||
renameSession: mockRenameSession,
|
||||
}),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, 'my-feature');
|
||||
|
||||
expect(mockRenameSession).toHaveBeenCalledWith(
|
||||
'test-session-id',
|
||||
'my-feature',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Session renamed to "my-feature"',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when SessionService fallback fails', async () => {
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn().mockReturnValue(undefined),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getSessionService: vi.fn().mockReturnValue({
|
||||
renameSession: vi.fn().mockResolvedValue(false),
|
||||
}),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: { config: mockConfig as never },
|
||||
});
|
||||
|
||||
const result = await renameCommand.action!(mockContext, 'my-feature');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to rename session.',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue