Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
376 changes: 376 additions & 0 deletions docs/design/session-title/session-title-design.md

Large diffs are not rendered by default.

286 changes: 284 additions & 2 deletions packages/cli/src/ui/commands/renameCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,31 @@ import { renameCommand } from './renameCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';

const tryGenerateSessionTitleMock = vi.fn();

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
(await importOriginal()) as typeof import('@qwen-code/qwen-code-core');
return {
...original,
tryGenerateSessionTitle: (...args: unknown[]) =>
tryGenerateSessionTitleMock(...args),
};
});

describe('renameCommand', () => {
let mockContext: CommandContext;

beforeEach(() => {
mockContext = createMockCommandContext();
tryGenerateSessionTitleMock.mockReset();
});

it('should have the correct name and description', () => {
expect(renameCommand.name).toBe('rename');
expect(renameCommand.description).toBe('Rename the current conversation');
expect(renameCommand.description).toBe(
'Rename the current conversation. --auto lets the fast model pick a title.',
);
});

it('should return error when config is not available', async () => {
Expand Down Expand Up @@ -103,7 +118,7 @@ describe('renameCommand', () => {

const result = await renameCommand.action!(mockContext, 'my-feature');

expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature');
expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature', 'manual');
expect(result).toEqual({
type: 'message',
messageType: 'info',
Expand All @@ -130,6 +145,7 @@ describe('renameCommand', () => {
expect(mockRenameSession).toHaveBeenCalledWith(
'test-session-id',
'my-feature',
'manual',
);
expect(result).toEqual({
type: 'message',
Expand Down Expand Up @@ -159,4 +175,270 @@ describe('renameCommand', () => {
content: 'Failed to rename session.',
});
});

describe('bare /rename model selection', () => {
// Pins the kebab-case path's model choice: bare `/rename` (no args)
// prefers fastModel when one is configured, falls back to the main
// model otherwise. Previous tests mocked `getHistory: []` which bailed
// before the model selection ran, leaving this regression-prone.
function mockConfigForKebab(opts: { fastModel?: string; model?: string }): {
config: unknown;
generateContent: ReturnType<typeof vi.fn>;
} {
const generateContent = vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'fix-login-bug' }] } }],
});
const config = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn().mockReturnValue(true),
}),
getFastModel: vi.fn().mockReturnValue(opts.fastModel),
getModel: vi.fn().mockReturnValue(opts.model ?? 'main-model'),
getGeminiClient: vi.fn().mockReturnValue({
getHistory: vi.fn().mockReturnValue([
{ role: 'user', parts: [{ text: 'fix the login bug' }] },
{
role: 'model',
parts: [{ text: 'Looking at the handler now.' }],
},
]),
}),
getContentGenerator: vi.fn().mockReturnValue({ generateContent }),
};
return { config, generateContent };
}

it('uses fastModel when configured', async () => {
const { config, generateContent } = mockConfigForKebab({
fastModel: 'qwen-turbo',
model: 'main-model',
});
mockContext = createMockCommandContext({
services: { config: config as never },
});

await renameCommand.action!(mockContext, '');

expect(generateContent).toHaveBeenCalledOnce();
expect(generateContent.mock.calls[0][0].model).toBe('qwen-turbo');
});

it('falls back to main model when fastModel is unset', async () => {
const { config, generateContent } = mockConfigForKebab({
fastModel: undefined,
model: 'main-model',
});
mockContext = createMockCommandContext({
services: { config: config as never },
});

await renameCommand.action!(mockContext, '');

expect(generateContent).toHaveBeenCalledOnce();
expect(generateContent.mock.calls[0][0].model).toBe('main-model');
});
});

describe('--auto flag', () => {
it('refuses --auto when no fast model is configured', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue(undefined),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(mockContext, '--auto');

expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'/rename --auto requires a fast model. Configure one with `/model --fast <model>`.',
});
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});

it('refuses --auto combined with a positional name', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(mockContext, '--auto my-name');

expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'/rename --auto does not take a name. Use `/rename <name>` to set a name yourself.',
});
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});

it('writes an auto-sourced title on --auto success', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: true,
title: 'Fix login button on mobile',
modelUsed: 'qwen-turbo',
});
const mockRecordCustomTitle = vi.fn().mockReturnValue(true);
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: mockRecordCustomTitle,
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(mockContext, '--auto');

expect(tryGenerateSessionTitleMock).toHaveBeenCalledOnce();
expect(mockRecordCustomTitle).toHaveBeenCalledWith(
'Fix login button on mobile',
'auto',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Session renamed to "Fix login button on mobile"',
});
});

it('surfaces empty_history reason with actionable hint', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'empty_history',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(mockContext, '--auto');

expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No conversation to title yet — send at least one message first.',
});
});

it('surfaces model_error reason distinctly', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'model_error',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(mockContext, '--auto');

expect(result).toMatchObject({
messageType: 'error',
});
expect((result as { content: string }).content).toMatch(
/rate limit, auth, or network error/,
);
});

it('rejects unknown flag with sentinel hint', async () => {
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(
mockContext,
'--my-label-with-dashes',
);

expect(result).toMatchObject({ messageType: 'error' });
const content = (result as { content: string }).content;
expect(content).toMatch(/Unknown flag "--my-label-with-dashes"/);
expect(content).toMatch(/\/rename -- --my-label-with-dashes/);
expect(tryGenerateSessionTitleMock).not.toHaveBeenCalled();
});

it('surfaces aborted reason when user cancels', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: false,
reason: 'aborted',
});
const mockConfig = {
getChatRecordingService: vi.fn().mockReturnValue({
recordCustomTitle: vi.fn(),
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(mockContext, '--auto');

expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Title generation was cancelled.',
});
});

it('falls back to SessionService.renameSession with auto source', async () => {
tryGenerateSessionTitleMock.mockResolvedValue({
ok: true,
title: 'Audit auth middleware',
modelUsed: 'qwen-turbo',
});
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,
}),
getFastModel: vi.fn().mockReturnValue('qwen-turbo'),
};
mockContext = createMockCommandContext({
services: { config: mockConfig as never },
});

const result = await renameCommand.action!(mockContext, '--auto');

expect(mockRenameSession).toHaveBeenCalledWith(
'test-session-id',
'Audit auth middleware',
'auto',
);
expect(result).toMatchObject({ messageType: 'info' });
});
});
});
Loading
Loading