feat(cli): add --delete flag to /exit command for session deletion (#19332)

Co-authored-by: David Pierce <davidapierce@google.com>
This commit is contained in:
Abdul Tawab
2026-04-29 22:20:57 +05:00
committed by GitHub
parent 2cf0c75a04
commit 011c0f9bc0
9 changed files with 272 additions and 2 deletions
@@ -33,11 +33,12 @@ describe('quitCommand', () => {
});
if (!quitCommand.action) throw new Error('Action is not defined');
const result = quitCommand.action(mockContext, 'quit');
const result = quitCommand.action(mockContext, '');
expect(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms
expect(result).toEqual({
type: 'quit',
deleteSession: false,
messages: [
{
type: 'user',
@@ -52,4 +53,54 @@ describe('quitCommand', () => {
],
});
});
it('sets deleteSession to true when --delete flag is provided', () => {
const mockContext = createMockCommandContext({
session: {
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
},
},
});
if (!quitCommand.action) throw new Error('Action is not defined');
const result = quitCommand.action(mockContext, '--delete');
expect(result).toEqual({
type: 'quit',
deleteSession: true,
messages: [
{
type: 'user',
text: '/quit',
id: expect.any(Number),
},
{
type: 'quit',
duration: '1h 0m 0s',
id: expect.any(Number),
},
],
});
});
it('does not set deleteSession for unrecognized args', () => {
const mockContext = createMockCommandContext({
session: {
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
},
},
});
if (!quitCommand.action) throw new Error('Action is not defined');
const result = quitCommand.action(mockContext, 'some-random-arg');
expect(result).toEqual(
expect.objectContaining({
type: 'quit',
deleteSession: false,
}),
);
});
});
+4 -1
View File
@@ -13,13 +13,16 @@ export const quitCommand: SlashCommand = {
description: 'Exit the cli',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => {
action: (context, args) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;
const wallDuration = now - sessionStartTime.getTime();
const deleteSession = args.trim() === '--delete';
return {
type: 'quit',
deleteSession,
messages: [
{
type: 'user',
+2
View File
@@ -108,6 +108,8 @@ export interface CommandContext {
export interface QuitActionReturn {
type: 'quit';
messages: HistoryItem[];
/** When true, the current session's history and temporary files will be deleted on exit. */
deleteSession?: boolean;
}
/**
@@ -646,6 +646,108 @@ describe('useSlashCommandProcessor', () => {
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should delete the current session when quit action has deleteSession flag', async () => {
const mockDeleteCurrentSessionAsync = vi
.fn()
.mockResolvedValue(undefined);
const mockClient = {
getChatRecordingService: vi.fn().mockReturnValue({
deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync,
}),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const quitAction = vi.fn().mockResolvedValue({
type: 'quit',
deleteSession: true,
messages: ['bye'],
});
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/exit --delete');
});
expect(mockDeleteCurrentSessionAsync).toHaveBeenCalled();
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should not delete session when quit action does not have deleteSession flag', async () => {
const mockDeleteCurrentSessionAsync = vi
.fn()
.mockResolvedValue(undefined);
const mockClient = {
getChatRecordingService: vi.fn().mockReturnValue({
deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync,
}),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const quitAction = vi.fn().mockResolvedValue({
type: 'quit',
messages: ['bye'],
});
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
expect(mockDeleteCurrentSessionAsync).not.toHaveBeenCalled();
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should still quit even if session deletion fails', async () => {
const mockClient = {
getChatRecordingService: vi.fn().mockReturnValue({
deleteCurrentSessionAsync: vi
.fn()
.mockRejectedValue(new Error('Deletion failed')),
}),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const quitAction = vi.fn().mockResolvedValue({
type: 'quit',
deleteSession: true,
messages: ['bye'],
});
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/exit --delete');
});
// Should still quit even though deletion threw
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should handle "submit_prompt" action returned from a file-based command', async () => {
const fileCommand = createTestCommand(
{
@@ -557,6 +557,18 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
}
case 'quit':
if (result.deleteSession) {
try {
const chatRecordingService = config
?.getGeminiClient()
?.getChatRecordingService();
if (chatRecordingService) {
await chatRecordingService.deleteCurrentSessionAsync();
}
} catch {
// Don't let deletion errors prevent exit.
}
}
actions.quit(result.messages);
return { type: 'handled' };