Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/cli/session-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ For named branch points inside a session, use chat checkpoints:
/resume resume decision-point
```

If a checkpoint with the same tag exists, Gemini CLI asks you to confirm before
overwriting it. Confirming the save keeps a single command entry in your chat
history. Canceling preserves the existing checkpoint.

Compatibility aliases:

- `/chat ...` works for the same commands.
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import {
SlashCommandStatus,
ToolConfirmationOutcome,
MCPDiscoveryState,
makeFakeConfig,
coreEvents,
type GeminiClient,
type ToolExecuteConfirmationDetails,
} from '@google/gemini-cli-core';

const {
Expand Down Expand Up @@ -1042,6 +1044,106 @@ describe('useSlashCommandProcessor', () => {
});
});

describe.each(['confirm_action', 'confirm_shell_commands'] as const)(
'%s continuations',
(confirmationType) => {
it.each(['success', 'failure', 'cancel'] as const)(
'records one history entry and one terminal event on %s',
async (outcome) => {
const action = vi
.fn<NonNullable<SlashCommand['action']>>()
.mockResolvedValueOnce(
confirmationType === 'confirm_action'
? {
type: 'confirm_action',
prompt: 'Overwrite the checkpoint?',
originalInvocation: { raw: '/test' },
}
: {
type: 'confirm_shell_commands',
commandsToConfirm: ['echo test'],
originalInvocation: { raw: '/test' },
},
);
if (outcome === 'failure') {
action.mockRejectedValue(new Error('Command failed'));
} else {
action.mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'Command completed',
});
}

const result = await setupProcessorHook({
builtinCommands: [createTestCommand({ action })],
});
let commandPromise!: ReturnType<
typeof result.current.handleSlashCommand
>;
await act(async () => {
commandPromise = result.current.handleSlashCommand('/test');
});

expect(action).toHaveBeenCalledTimes(1);
expect(logSlashCommand).not.toHaveBeenCalled();

// An unrelated update must not expose a duplicate command after
// confirmation, even when consecutive-history deduplication cannot help.
result.current.commandContext.ui.addItem({
type: MessageType.INFO,
text: 'Background update',
});

await act(async () => {
if (confirmationType === 'confirm_action') {
expect(result.current.confirmationRequest).not.toBeNull();
result.current.confirmationRequest!.onConfirm(
outcome !== 'cancel',
);
} else {
const pendingItem = result.current.pendingHistoryItems[0];
if (pendingItem?.type !== 'tool_group') {
throw new Error('Expected a pending shell confirmation');
}
// Client-initiated shell confirmations retain their callback.
const confirmation = pendingItem.tools[0]
.confirmationDetails as ToolExecuteConfirmationDetails;
expect(confirmation).toBeDefined();
await confirmation.onConfirm(
outcome === 'cancel'
? ToolConfirmationOutcome.Cancel
: ToolConfirmationOutcome.ProceedOnce,
);
}
await commandPromise;
});

expect(action).toHaveBeenCalledTimes(outcome === 'cancel' ? 1 : 2);
expect(
mockAddItem.mock.calls.filter(
([item]) => item.type === MessageType.USER,
),
).toEqual([
[{ type: MessageType.USER, text: '/test' }, expect.any(Number)],
]);
expect(logSlashCommand).toHaveBeenCalledExactlyOnceWith(
mockConfig,
expect.objectContaining({
command: 'test',
status:
outcome === 'failure'
? SlashCommandStatus.ERROR
: SlashCommandStatus.SUCCESS,
}),
);
expect(result.current.confirmationRequest).toBeNull();
expect(result.current.pendingHistoryItems).toEqual([]);
},
);
},
);

describe('Slash Command Logging', () => {
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
let loggingTestCommands: SlashCommand[];
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/ui/hooks/slashCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ export const useSlashCommandProcessor = (
}

let hasError = false;
let commandReinvoked = false;

const subcommand =
resolvedCommandPath.length > 1
Expand Down Expand Up @@ -641,6 +642,7 @@ export const useSlashCommandProcessor = (
);
}

commandReinvoked = true;
return await handleSlashCommand(
result.originalInvocation.raw,
// Pass the approved commands as a one-time grant for this execution.
Expand Down Expand Up @@ -673,10 +675,12 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
}

commandReinvoked = true;
return await handleSlashCommand(
result.originalInvocation.raw,
undefined,
true,
false, // Do not add to history again
);
}
case 'custom_dialog': {
Expand Down Expand Up @@ -727,7 +731,13 @@ export const useSlashCommandProcessor = (
);
return { type: 'handled' };
} finally {
if (config && resolvedCommandPath[0] && !hasError) {
// A confirmed command's resumed invocation records its final outcome.
if (
config &&
resolvedCommandPath[0] &&
!hasError &&
!commandReinvoked
) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
subcommand,
Expand Down