mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
feat: export session to file and import via flag (#26514)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user