feat: export session to file and import via flag (#26514)

This commit is contained in:
Coco Sheng
2026-05-08 11:53:52 -04:00
committed by GitHub
parent 2d10691acb
commit 3805640530
13 changed files with 602 additions and 33 deletions
+2 -2
View File
@@ -234,7 +234,7 @@ describe('parseArguments', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should fail if both --resume and --session-id are provided', async () => {
it('should fail if multiple session flags are provided', async () => {
process.argv = [
'node',
'script.js',
@@ -255,7 +255,7 @@ describe('parseArguments', () => {
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --resume (-r) and --session-id together',
'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.',
),
);
});
+14 -2
View File
@@ -97,6 +97,7 @@ export interface CliArgs {
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
sessionFile?: string | undefined;
sessionId: string | undefined;
listSessions: boolean | undefined;
deleteSession: string | undefined;
@@ -239,8 +240,14 @@ export async function parseArguments(
? query.length > 0
: !!query;
if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
return 'Cannot use both --resume (-r) and --session-id together';
const sessionFlags = [
argv['resume'] !== undefined,
argv['session-id'] !== undefined,
argv['session-file'] !== undefined,
].filter(Boolean).length;
if (sessionFlags > 1) {
return 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.';
}
if (argv['prompt'] && hasPositionalQuery) {
@@ -412,6 +419,11 @@ export async function parseArguments(
return trimmed;
},
})
.option('session-file', {
type: 'string',
nargs: 1,
description: 'Load a session from a JSON file',
})
.option('session-id', {
type: 'string',
nargs: 1,
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { parseArguments } from './config.js';
import { createTestMergedSettings } from './settings.js';
describe('parseArguments mutual exclusivity', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const combinations = [
['--resume', '--session-id', 'test-id'],
['--resume', '--session-file', 'test.json'],
['--session-id', 'test-id', '--session-file', 'test.json'],
['--resume', '--session-id', 'test-id', '--session-file', 'test.json'],
];
combinations.forEach((args) => {
it(`should fail if ${args.filter((a) => a.startsWith('--')).join(' and ')} are provided`, async () => {
process.argv = ['node', 'script.js', ...args];
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
await expect(parseArguments(createTestMergedSettings())).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.',
),
);
});
});
});
+104 -28
View File
@@ -44,6 +44,7 @@ import {
type Config,
type ResumedSessionData,
type StartupWarning,
type ConversationRecord,
WarningPriority,
debugLogger,
coreEvents,
@@ -828,14 +829,14 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should handle session selector error', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
resolveSession: vi
.fn()
.mockRejectedValue(new Error('Session not found')),
}) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
resolveSession: vi
.fn()
.mockRejectedValue(new Error('Session not found')),
} as unknown as InstanceType<typeof SessionSelector>;
});
const processExitSpy = vi
.spyOn(process, 'exit')
@@ -884,14 +885,14 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should start normally with a warning when no sessions found for resume', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
resolveSession: vi
.fn()
.mockRejectedValue(SessionError.noSessionsFound()),
}) as unknown as InstanceType<typeof SessionSelector>,
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
resolveSession: vi
.fn()
.mockRejectedValue(SessionError.noSessionsFound()),
} as unknown as InstanceType<typeof SessionSelector>;
});
const processExitSpy = vi
.spyOn(process, 'exit')
@@ -1068,13 +1069,88 @@ describe('resolveSessionId', () => {
expect(resumedSessionData).toBeUndefined();
});
it('should import from session file when sessionFile is provided', async () => {
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
sessionExists: vi.fn().mockResolvedValue(false),
} as unknown as InstanceType<typeof SessionSelector>;
});
const coreModule = await import('@google/gemini-cli-core');
vi.spyOn(coreModule, 'loadConversationRecord').mockResolvedValueOnce({
sessionId: 'old-session-id',
projectHash: 'hash',
startTime: 'time',
lastUpdated: 'time',
messages: [
{ type: 'info', content: 'Old info', id: '1' },
{ type: 'user', content: 'Hello', id: '2' },
{ type: 'gemini', content: 'Hi', id: '3' },
{ type: 'error', content: 'Old error', id: '4' },
{ type: 'user', id: '5' }, // Missing content
null, // Null object
{ type: 'unknown', content: 'Something', id: '6' }, // Unknown type
],
} as unknown as ConversationRecord);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
try {
const { sessionId, resumedSessionData } = await resolveSessionId(
undefined,
undefined,
'dummy-session.json',
);
expect(sessionId).toBeDefined();
expect(sessionId).not.toBe('old-session-id'); // A new session ID should be created
expect(resumedSessionData).toBeDefined();
expect(resumedSessionData?.conversation.sessionId).toBe(sessionId); // Overwritten
// Verify messages: should have 1 info (the new import confirmation) + 2 valid conversation messages
// Invalid messages (missing content, null, unknown type) and transient messages should be filtered out.
expect(resumedSessionData?.conversation.messages).toHaveLength(3);
expect(resumedSessionData?.conversation.messages![0]).toMatchObject({
type: 'info',
content: expect.stringContaining('Imported session from'),
});
expect(resumedSessionData?.conversation.messages![1]).toMatchObject({
type: 'user',
content: 'Hello',
});
expect(resumedSessionData?.conversation.messages![2]).toMatchObject({
type: 'gemini',
content: 'Hi',
});
expect(resumedSessionData?.filePath).toContain(sessionId.slice(0, 8)); // New path
} catch (e) {
if (e instanceof MockProcessExitError) {
throw new Error(
'process.exit called with: ' +
JSON.stringify(emitFeedbackSpy.mock.calls),
);
}
throw e;
} finally {
emitFeedbackSpy.mockRestore();
processExitSpy.mockRestore();
}
});
it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
sessionExists: vi.fn().mockResolvedValue(true),
}) as unknown as InstanceType<typeof SessionSelector>,
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
sessionExists: vi.fn().mockResolvedValue(true),
} as unknown as InstanceType<typeof SessionSelector>;
});
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const processExitSpy = vi
@@ -1100,12 +1176,12 @@ describe('resolveSessionId', () => {
});
it('should return provided sessionId when it does not exist', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
sessionExists: vi.fn().mockResolvedValue(false),
}) as unknown as InstanceType<typeof SessionSelector>,
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
sessionExists: vi.fn().mockResolvedValue(false),
} as unknown as InstanceType<typeof SessionSelector>;
});
const { sessionId, resumedSessionData } = await resolveSessionId(
undefined,
'new-id',
+82 -1
View File
@@ -35,6 +35,9 @@ import {
debugLogger,
isHeadlessMode,
Storage,
getProjectHash,
loadConversationRecord,
type MessageRecord,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -44,6 +47,8 @@ import { createHash } from 'node:crypto';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import * as path from 'node:path';
import * as fsPromises from 'node:fs/promises';
import { start_sandbox } from './utils/sandbox.js';
import {
loadSettings,
@@ -194,11 +199,12 @@ ${reason.stack}`
export async function resolveSessionId(
resumeArg: string | undefined,
sessionIdArg?: string | undefined,
sessionFileArg?: string | undefined,
): Promise<{
sessionId: string;
resumedSessionData?: ResumedSessionData;
}> {
if (!resumeArg && !sessionIdArg) {
if (!resumeArg && !sessionIdArg && !sessionFileArg) {
return { sessionId: createSessionId() };
}
@@ -207,6 +213,80 @@ export async function resolveSessionId(
const sessionSelector = new SessionSelector(storage);
if (sessionFileArg) {
try {
const sessionData = await loadConversationRecord(sessionFileArg);
if (!sessionData) {
throw new Error(`File not found or invalid format: ${sessionFileArg}`);
}
const now = Date.now();
const isoNow = new Date(now).toISOString();
// Filter out old system/info messages that are specific to the previous run
// and only keep actual conversation messages (user/gemini).
// Best effort parse: ensure message is an object and has required fields.
sessionData.messages = (sessionData.messages || []).filter(
(m) =>
typeof m === 'object' &&
m !== null &&
(m.type === 'user' || m.type === 'gemini') &&
m.content !== undefined,
);
// Add a single info message to the history to confirm the import
sessionData.messages.unshift({
id: `import-${now}`,
type: 'info',
content: `Imported session from ${sessionFileArg}`,
timestamp: isoNow,
} as MessageRecord);
const newSessionId = createSessionId();
sessionData.sessionId = newSessionId;
sessionData.projectHash = getProjectHash(storage.getProjectRoot());
sessionData.startTime = isoNow;
sessionData.lastUpdated = isoNow;
const chatsDir = path.join(storage.getProjectTempDir(), 'chats');
const newSessionPath = path.join(
chatsDir,
`session-${now}-${newSessionId.slice(0, 8)}.jsonl`,
);
const { messages: _messages, ...initialMetadata } = sessionData;
const lines = [JSON.stringify(initialMetadata)];
if (sessionData.messages) {
for (const msg of sessionData.messages) {
lines.push(JSON.stringify(msg));
}
}
await fsPromises.mkdir(chatsDir, { recursive: true });
await fsPromises.writeFile(
newSessionPath,
lines.join('\n') + '\n',
'utf-8',
);
return {
sessionId: newSessionId,
resumedSessionData: {
conversation: sessionData,
filePath: newSessionPath,
},
};
} catch (error) {
coreEvents.emitFeedback(
'error',
`Error importing session from file: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_INPUT_ERROR);
}
}
if (sessionIdArg) {
if (await sessionSelector.sessionExists(sessionIdArg)) {
coreEvents.emitFeedback(
@@ -340,6 +420,7 @@ export async function main() {
const { sessionId, resumedSessionData } = await resolveSessionId(
argv.resume,
argv.sessionId,
argv.sessionFile,
);
if (
@@ -30,6 +30,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { exportSessionCommand } from '../ui/commands/exportSessionCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
@@ -135,6 +136,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
copyCommand,
corgiCommand,
docsCommand,
exportSessionCommand,
directoryCommand,
editorCommand,
...(this.config?.getExtensionsEnabled() === false
@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { exportSessionCommand } from './exportSessionCommand.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { SessionSelector } from '../../utils/sessionUtils.js';
import type { CommandContext } from './types.js';
import { Storage, type ConversationRecord } from '@google/gemini-cli-core';
vi.mock('node:fs/promises');
vi.mock('../../utils/sessionUtils.js');
describe('exportSessionCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(Storage.prototype, 'initialize').mockResolvedValue(undefined);
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
path.join(path.sep, 'tmp', 'mock-dir'),
);
mockContext = {
services: {
agentContext: {
config: {
sessionId: 'test-session-id',
getSessionId: () => 'test-session-id',
storage: new Storage(process.cwd()),
},
},
},
invocation: {
args: ' export.json ',
name: 'export-session',
raw: '/export-session export.json',
},
ui: {
addItem: vi.fn(),
setPendingItem: vi.fn(),
pendingItem: null,
},
} as unknown as CommandContext;
});
it('should return error if no path is provided', async () => {
mockContext.invocation!.args = ' ';
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Please provide a file path'),
});
});
it('should return error if sessionId is missing', async () => {
mockContext.services.agentContext!.config.getSessionId = () =>
undefined as unknown as string;
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
});
});
it('should export the session successfully', async () => {
const mockSessionData: ConversationRecord = {
sessionId: 'test-session-id',
messages: [],
projectHash: 'hash',
startTime: 'time',
lastUpdated: 'time',
};
vi.mocked(SessionSelector.prototype.resolveSession).mockResolvedValue({
sessionData: mockSessionData,
sessionPath: path.join(
path.sep,
'tmp',
'mock-dir',
'chats',
'session.jsonl',
),
displayInfo: 'test',
});
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toBeUndefined();
expect(fs.writeFile).toHaveBeenCalledWith(
path.resolve(process.cwd(), 'export.json'),
JSON.stringify(mockSessionData, null, 2),
'utf-8',
);
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'export_session',
exportSession: { isPending: true },
}),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'export_session',
exportSession: {
isPending: false,
targetPath: expect.stringContaining('export.json'),
},
}),
expect.any(Number),
);
expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null);
});
it('should return error if resolveSession fails', async () => {
vi.mocked(SessionSelector.prototype.resolveSession).mockRejectedValue(
new Error('Session not found'),
);
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to export session: Session not found',
});
});
});
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { MessageType, type HistoryItemExportSession } from '../types.js';
import { SessionSelector } from '../../utils/sessionUtils.js';
export const exportSessionCommand: SlashCommand = {
name: 'export-session',
description: 'Export the current session to a JSON file',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn | void> => {
const { ui } = context;
const args = context.invocation?.args.trim();
if (!args) {
return {
type: 'message',
messageType: 'error',
content:
'Please provide a file path to export the session to. Example: /export-session ./my-session.json',
};
}
const sessionId = context.services.agentContext?.config.getSessionId();
if (!sessionId) {
return {
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
};
}
if (ui.pendingItem) {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Operation already in progress, please wait.',
},
Date.now(),
);
return;
}
const pendingMessage: HistoryItemExportSession = {
type: MessageType.EXPORT_SESSION,
exportSession: {
isPending: true,
},
};
try {
ui.setPendingItem(pendingMessage);
const storage = context.services.agentContext!.config.storage;
const sessionSelector = new SessionSelector(storage);
const { sessionData } = await sessionSelector.resolveSession(sessionId);
const targetPath = path.resolve(process.cwd(), args);
await fs.writeFile(
targetPath,
JSON.stringify(sessionData, null, 2),
'utf-8',
);
ui.addItem(
{
type: MessageType.EXPORT_SESSION,
exportSession: {
isPending: false,
targetPath,
},
} as HistoryItemExportSession,
Date.now(),
);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to export session: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
} finally {
ui.setPendingItem(null);
}
},
};
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { MessageType, type HistoryItem } from '../types.js';
@@ -198,6 +199,25 @@ describe('<HistoryItemDisplay />', () => {
unmount();
});
it('renders ExportSessionMessage for "export_session" type', async () => {
const testPath = path.join(path.sep, 'test', 'path.json');
const item: HistoryItem = {
...baseItem,
type: 'export_session',
exportSession: {
isPending: false,
targetPath: testPath,
},
};
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain(
`Successfully exported session to ${testPath}`,
);
unmount();
});
it('should escape ANSI codes in text content', async () => {
const historyItem: HistoryItem = {
id: 1,
@@ -17,6 +17,7 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { ToolGroupDisplay } from './messages/ToolGroupDisplay.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { ExportSessionMessage } from './messages/ExportSessionMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { SubagentHistoryMessage } from './messages/SubagentHistoryMessage.js';
import { Box } from 'ink';
@@ -211,6 +212,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'compression' && (
<CompressionMessage compression={itemForDisplay.compression} />
)}
{itemForDisplay.type === 'export_session' && (
<ExportSessionMessage exportSession={itemForDisplay.exportSession} />
)}
{itemForDisplay.type === 'extensions_list' && (
<ExtensionsList extensions={itemForDisplay.extensions} />
)}
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import { render } from '../../../test-utils/render.js';
import { Text } from 'ink';
import { describe, it, expect, vi } from 'vitest';
import { ExportSessionMessage } from './ExportSessionMessage.js';
vi.mock('../CliSpinner.js', () => ({
CliSpinner: () => <Text>[spinner]</Text>,
}));
describe('ExportSessionMessage', () => {
it('renders pending state correctly', async () => {
const { lastFrame } = await render(
<ExportSessionMessage exportSession={{ isPending: true }} />,
);
expect(lastFrame()).toContain('[spinner]');
expect(lastFrame()).toContain('Exporting session...');
});
it('renders success state correctly', async () => {
const testPath = path.join(path.sep, 'path', 'to', 'session.json');
const { lastFrame } = await render(
<ExportSessionMessage
exportSession={{
isPending: false,
targetPath: testPath,
}}
/>,
);
expect(lastFrame()).toContain('✓');
expect(lastFrame()).toContain(
`Successfully exported session to ${testPath}`,
);
});
});
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { JSX } from 'react';
import { Box, Text } from 'ink';
import type { ExportSessionProps } from '../../types.js';
import { CliSpinner } from '../CliSpinner.js';
import { theme } from '../../semantic-colors.js';
export interface ExportSessionDisplayProps {
exportSession: ExportSessionProps;
}
/*
* Export session messages appear when the /export-session command is run, and show a loading spinner
* while export is in progress, followed by a success message.
*/
export function ExportSessionMessage({
exportSession,
}: ExportSessionDisplayProps): JSX.Element {
const { isPending, targetPath } = exportSession;
return (
<Box flexDirection="row" marginTop={1}>
<Box marginRight={1}>
{isPending ? (
<CliSpinner type="dots" />
) : (
<Text color={theme.status.success}></Text>
)}
</Box>
<Box>
<Text color={isPending ? theme.text.accent : theme.status.success}>
{isPending
? 'Exporting session...'
: `Successfully exported session to ${targetPath}`}
</Text>
</Box>
</Box>
);
}
+12
View File
@@ -148,6 +148,11 @@ export interface CompressionProps {
compressionStatus: CompressionStatus | null;
}
export interface ExportSessionProps {
isPending: boolean;
targetPath?: string;
}
/**
* For use when you want no icon.
*/
@@ -284,6 +289,11 @@ export type HistoryItemCompression = HistoryItemBase & {
compression: CompressionProps;
};
export type HistoryItemExportSession = HistoryItemBase & {
type: 'export_session';
exportSession: ExportSessionProps;
};
export type HistoryItemExtensionsList = HistoryItemBase & {
type: 'extensions_list';
extensions: GeminiCLIExtension[];
@@ -427,6 +437,7 @@ export type HistoryItemWithoutId =
| HistoryItemModel
| HistoryItemQuit
| HistoryItemCompression
| HistoryItemExportSession
| HistoryItemExtensionsList
| HistoryItemToolsList
| HistoryItemSkillsList
@@ -454,6 +465,7 @@ export enum MessageType {
QUIT = 'quit',
GEMINI = 'gemini',
COMPRESSION = 'compression',
EXPORT_SESSION = 'export_session',
EXTENSIONS_LIST = 'extensions_list',
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',