Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ab50e3d
feat(sessions): add resuming to geminiChat and add CLI flags for sess…
bl-ue Oct 8, 2025
b24c84d
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 8, 2025
2193087
Update packages/cli/src/utils/sessions.ts
bl-ue Oct 8, 2025
1daebb4
Address bot review
bl-ue Oct 8, 2025
5a702e3
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 14, 2025
0c9d470
Add a test argv.resume && !argv.prompt && !process.stdin.isTTY
bl-ue Oct 14, 2025
5c67506
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 15, 2025
393377a
Setup interactive resume
bl-ue Oct 17, 2025
2b40598
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 17, 2025
2836b66
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 18, 2025
d3fa6c6
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 18, 2025
666875d
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 20, 2025
9456f2a
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 20, 2025
4cc24a1
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 20, 2025
d4e0507
Extract UI resume logic to a useSessionResume hook
bl-ue Oct 20, 2025
ae2bfd3
Fuller deleteSession
bl-ue Oct 20, 2025
ee8bbe8
Bug fix
bl-ue Oct 20, 2025
82c34b3
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 22, 2025
46a7042
Apply @jk-kashe's fix for a bug where resuming wouldn't work if authe…
bl-ue Oct 23, 2025
fba8c7c
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 26, 2025
7bb6b06
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 27, 2025
e01018d
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 29, 2025
20077cf
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 29, 2025
460a843
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Oct 30, 2025
ee9277e
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Nov 1, 2025
b1a3dc2
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Nov 6, 2025
14200bd
Fix
bl-ue Nov 6, 2025
5501926
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Nov 6, 2025
66d1911
Merge branch 'main' into add-cli-flags-for-session-mgmt
bl-ue Nov 10, 2025
b14040c
Fix new test failures from merges
bl-ue Nov 10, 2025
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
30 changes: 30 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,36 @@ describe('parseArguments', () => {
mockConsoleError.mockRestore();
});

it('should throw an error when resuming a session without prompt in non-interactive mode', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '--resume', 'session-id'];

const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});

const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});

try {
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);

expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
),
);
} finally {
mockExit.mockRestore();
mockConsoleError.mockRestore();
process.stdin.isTTY = originalIsTTY;
}
});

it('should support comma-separated values for --allowed-tools', async () => {
process.argv = [
'node',
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export interface CliArgs {
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | 'latest' | undefined;
listSessions: boolean | undefined;
deleteSession: string | undefined;
includeDirectories: string[] | undefined;
screenReader: boolean | undefined;
useSmartEdit: boolean | undefined;
Expand Down Expand Up @@ -172,6 +175,35 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('resume', {
alias: 'r',
type: 'string',
// `skipValidation` so that we can distinguish between it being passed with a value, without
// one, and not being passed at all.
skipValidation: true,
description:
'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What does index number mean here? E.g. is it 1-based or 0-based? Does it start from the oldest or the newest session?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1-based, starting from the oldest. So the first (oldest) session is 1, the second is 2, etc. The indices are shown in the output of --list-sessions:

$ gemini --list-sessions

Available sessions for this project (4):

  1. Hi there! (10 days ago) [6c9aadd0-fe64-4ce6-b389-8c0729aaa027]
  2. Another chat (8 days ago) [5420cc78-eb43-483e-a6b4-7efd4c4fc36d]
  3. A third chat (2 days ago) [9e54ee88-9cf9-4936-8ea8-c17437a008f6]

At first I only implemented index-based identification. Then per @shuangliu1993's suggestion I added the ability to resume by the fixed ID as well. I thought I'd let the two methods coexist for flexibility, but I'll remove the index-based one if you think the ID is enough.

coerce: (value: string): string => {
// When --resume passed with a value (`gemini --resume 123`): value = "123" (string)
// When --resume passed without a value (`gemini --resume`): value = "" (string)
// When --resume not passed at all: this `coerce` function is not called at all, and
// `yargsInstance.argv.resume` is undefined.
if (value === '') {
Comment thread
bl-ue marked this conversation as resolved.
return 'latest';
}
return value;
},
})
.option('list-sessions', {
type: 'boolean',
description:
'List available sessions for the current project and exit.',
})
.option('delete-session', {
type: 'string',
description:
'Delete a session by index number (use --list-sessions to see available sessions).',
})
.option('include-directories', {
type: 'array',
string: true,
Expand Down Expand Up @@ -227,6 +259,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv.resume && !argv.prompt && !process.stdin.isTTY) {
Comment thread
bl-ue marked this conversation as resolved.
throw new Error(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
);
}
if (argv.yolo && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
Expand Down Expand Up @@ -585,6 +622,8 @@ export async function loadCliConfig(
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from './gemini.js';
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import { type Config } from '@google/gemini-cli-core';
import { type Config, type ResumedSessionData } from '@google/gemini-cli-core';
import { act } from 'react';
import { type InitializationResult } from './core/initializer.js';

Expand Down Expand Up @@ -189,6 +189,8 @@ describe('gemini.tsx main function', () => {
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
initialize: vi.fn(),
Expand Down Expand Up @@ -339,6 +341,8 @@ describe('gemini.tsx main function kitty protocol', () => {
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
initialize: vi.fn(),
Expand Down Expand Up @@ -391,6 +395,9 @@ describe('gemini.tsx main function kitty protocol', () => {
screenReader: undefined,
useSmartEdit: undefined,
useWriteTodos: undefined,
resume: undefined,
listSessions: undefined,
deleteSession: undefined,
outputFormat: undefined,
fakeResponses: undefined,
recordResponses: undefined,
Expand Down Expand Up @@ -488,6 +495,7 @@ describe('startInteractiveUI', () => {
settings: LoadedSettings,
startupWarnings: string[],
workspaceRoot: string,
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
await act(async () => {
Expand All @@ -496,6 +504,7 @@ describe('startInteractiveUI', () => {
settings,
startupWarnings,
workspaceRoot,
resumedSessionData,
initializationResult,
);
});
Expand All @@ -510,6 +519,7 @@ describe('startInteractiveUI', () => {
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
undefined,
mockInitializationResult,
);

Expand Down Expand Up @@ -538,6 +548,7 @@ describe('startInteractiveUI', () => {
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
undefined,
mockInitializationResult,
);

Expand All @@ -563,6 +574,7 @@ describe('startInteractiveUI', () => {
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
undefined,
mockInitializationResult,
);

Expand All @@ -579,6 +591,7 @@ describe('startInteractiveUI', () => {
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
undefined,
mockInitializationResult,
);

Expand Down Expand Up @@ -610,6 +623,7 @@ describe('startInteractiveUI', () => {
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
undefined,
mockInitializationResult,
);

Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
runExitCleanup,
} from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import { type Config } from '@google/gemini-cli-core';
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
import {
sessionId,
logUserPrompt,
Expand All @@ -55,6 +55,7 @@ import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.j
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
import { SessionSelector } from './utils/sessionUtils.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { MouseProvider } from './ui/contexts/MouseContext.js';
Expand All @@ -68,6 +69,7 @@ import {
relaunchOnExitCode,
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { ExtensionManager } from './config/extension-manager.js';
import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
Expand Down Expand Up @@ -151,6 +153,7 @@ export async function startInteractiveUI(
settings: LoadedSettings,
startupWarnings: string[],
workspaceRoot: string = process.cwd(),
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
// When not in screen reader mode, disable line wrapping.
Expand Down Expand Up @@ -205,6 +208,7 @@ export async function startInteractiveUI(
settings={settings}
startupWarnings={startupWarnings}
version={version}
resumedSessionData={resumedSessionData}
initializationResult={initializationResult}
/>
</VimModeProvider>
Expand Down Expand Up @@ -414,6 +418,19 @@ export async function main() {
process.exit(0);
}

// Handle --list-sessions flag
if (config.getListSessions()) {
await listSessions(config);
process.exit(0);
}

// Handle --delete-session flag
const sessionToDelete = config.getDeleteSession();
if (sessionToDelete) {
await deleteSession(config, sessionToDelete);
process.exit(0);
}

const wasRaw = process.stdin.isRaw;
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
// Set this as early as possible to avoid spurious characters from
Expand Down Expand Up @@ -454,13 +471,34 @@ export async function main() {
...(await getUserStartupWarnings()),
];

// Handle --resume flag
let resumedSessionData: ResumedSessionData | undefined = undefined;
if (argv.resume) {
const sessionSelector = new SessionSelector(config);
try {
const result = await sessionSelector.resolveSession(argv.resume);
resumedSessionData = {
conversation: result.sessionData,
filePath: result.sessionPath,
};
// Use the existing session ID to continue recording to the same session
config.setSessionId(resumedSessionData.conversation.sessionId);
} catch (error) {
console.error(
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
process.exit(1);
}
}

// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
await startInteractiveUI(
config,
settings,
startupWarnings,
process.cwd(),
resumedSessionData,
initializationResult,
);
return;
Expand Down Expand Up @@ -514,6 +552,7 @@ export async function main() {
input,
prompt_id,
hasDeprecatedPromptArg,
resumedSessionData,
});
// Call cleanup before process.exit, which causes cleanup to not run
await runExitCleanup();
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('runNonInteractive', () => {
let processStderrSpy: MockInstance;
let mockGeminiClient: {
sendMessageStream: Mock;
resumeChat: Mock;
getChatRecordingService: Mock;
};
const MOCK_SESSION_METRICS: SessionMetrics = {
Expand Down Expand Up @@ -142,6 +143,7 @@ describe('runNonInteractive', () => {

mockGeminiClient = {
sendMessageStream: vi.fn(),
resumeChat: vi.fn().mockResolvedValue(undefined),
getChatRecordingService: vi.fn(() => ({
initialize: vi.fn(),
recordMessage: vi.fn(),
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import type {
Config,
ToolCallRequestInfo,
ResumedSessionData,
CompletedToolCall,
UserFeedbackPayload,
} from '@google/gemini-cli-core';
Expand All @@ -32,6 +33,7 @@ import {
import type { Content, Part } from '@google/genai';
import readline from 'node:readline';

import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
Expand All @@ -49,6 +51,7 @@ interface RunNonInteractiveParams {
input: string;
prompt_id: string;
hasDeprecatedPromptArg?: boolean;
resumedSessionData?: ResumedSessionData;
}

export async function runNonInteractive({
Expand All @@ -57,6 +60,7 @@ export async function runNonInteractive({
input,
prompt_id,
hasDeprecatedPromptArg,
resumedSessionData,
}: RunNonInteractiveParams): Promise<void> {
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
Expand Down Expand Up @@ -185,6 +189,16 @@ export async function runNonInteractive({

const geminiClient = config.getGeminiClient();

// Initialize chat. Resume if resume data is passed.
if (resumedSessionData) {
await geminiClient.resumeChat(
convertSessionToHistoryFormats(
resumedSessionData.conversation.messages,
).clientHistory,
resumedSessionData,
);
}

// Emit init event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
Expand Down
Loading