Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
90eb3d2
feat: implement session export and import
cocosheng-g May 5, 2026
eca3bd3
fix: address PR feedback for session import and export
cocosheng-g May 5, 2026
0e88678
fix: resolve TS compilation errors for session export
cocosheng-g May 5, 2026
af1438f
fix: refactor gemini.tsx based on PR review
cocosheng-g May 5, 2026
21125e3
chore: fix lint errors in gemini.tsx and tests
cocosheng-g May 5, 2026
030b9cc
chore: remove pr.md from the repository
cocosheng-g May 5, 2026
b8f4d0c
fix: resolve TS2322 in exportSessionCommand.test.ts
cocosheng-g May 5, 2026
d963248
chore: fix lint and build errors, stabilize tests
cocosheng-g May 5, 2026
0b695e4
feat: make --session-file, --resume, and --session-id mutually exclusive
cocosheng-g May 5, 2026
f6ed220
test: add tests for session flag mutual exclusivity
cocosheng-g May 5, 2026
4bba538
feat: add progress indicator for /export-session command
cocosheng-g May 5, 2026
4ed15d7
feat: add import confirmation message to session history
cocosheng-g May 5, 2026
20e2a78
feat: filter transient messages during session import
cocosheng-g May 5, 2026
a3e4fa9
feat: implement robust 'best effort' session import and add unit tests
cocosheng-g May 6, 2026
40b1a3c
fix: resolve build errors in ExportSessionMessage.test.tsx
cocosheng-g May 6, 2026
c47f1f6
chore: fix test failure and resolve all remaining lint errors
cocosheng-g May 6, 2026
4dac6b9
chore: fix last 4 ESLint errors
cocosheng-g May 6, 2026
5489349
chore: resolve remaining lint and build issues
cocosheng-g May 6, 2026
f289d30
chore: apply prettier formatting
cocosheng-g May 6, 2026
a7b6458
fix: resolve Windows path issue in exportSessionCommand tests
cocosheng-g May 6, 2026
5092115
fix: resolve remaining cross-platform path issues in tests
cocosheng-g May 6, 2026
f05b62d
test: fix missing path import in HistoryItemDisplay.test.tsx
cocosheng-g May 6, 2026
e698107
style: run prettier to fix formatting
cocosheng-g May 6, 2026
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: 2 additions & 2 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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.',
),
);
});
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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',
})
Comment thread
cocosheng-g marked this conversation as resolved.
Comment thread
cocosheng-g marked this conversation as resolved.
.option('session-id', {
type: 'string',
nargs: 1,
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/config/mutual-exclusivity.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
),
);
});
});
});
132 changes: 104 additions & 28 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type Config,
type ResumedSessionData,
type StartupWarning,
type ConversationRecord,
WarningPriority,
debugLogger,
coreEvents,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down
83 changes: 82 additions & 1 deletion packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ import {
debugLogger,
isHeadlessMode,
Storage,
getProjectHash,
loadConversationRecord,
type MessageRecord,
} from '@google/gemini-cli-core';

import { loadCliConfig, parseArguments } from './config/config.js';
Expand All @@ -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,
Expand Down Expand Up @@ -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() };
}

Expand All @@ -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`,
);
Comment thread
cocosheng-g marked this conversation as resolved.

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(
Expand Down Expand Up @@ -340,6 +420,7 @@ export async function main() {
const { sessionId, resumedSessionData } = await resolveSessionId(
argv.resume,
argv.sessionId,
argv.sessionFile,
);

if (
Expand Down
Loading
Loading