Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin 2025-10-23 09:27:04 +08:00 committed by GitHub
parent 096fabb5d6
commit eb95c131be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
644 changed files with 70389 additions and 23709 deletions

View file

@ -17,13 +17,15 @@ import type { Content } from '@google/genai';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand } from './chatCommand.js';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
}));
describe('chatCommand', () => {
@ -37,7 +39,7 @@ describe('chatCommand', () => {
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete',
name: 'list' | 'save' | 'resume' | 'delete' | 'share',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
@ -86,7 +88,7 @@ describe('chatCommand', () => {
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(4);
expect(chatCommand.subCommands).toHaveLength(5);
});
describe('list subcommand', () => {
@ -407,4 +409,293 @@ describe('chatCommand', () => {
});
});
});
describe('share subcommand', () => {
let shareCommand: SlashCommand;
const mockHistory = [
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
beforeEach(() => {
shareCommand = getSubCommand('share');
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve('/usr/local/google/home/myuser/gemini-cli'),
);
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockGetHistory.mockReturnValue(mockHistory);
mockFs.writeFile.mockClear();
});
it('should default to a json file if no path is provided', async () => {
const result = await shareCommand?.action?.(mockContext, '');
const expectedPath = path.join(
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a JSON file', async () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a Markdown file', async () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent = `🧑‍💻 ## USER
context
---
## MODEL
context response
---
🧑💻 ## USER
Hello
---
## MODEL
Hi there!`;
expect(actualContent).toEqual(expectedContent);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
});
});
it('should inform if there is no conversation to share', async () => {
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
});
});
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${error.message}`,
});
});
it('should output valid JSON schema', async () => {
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
});
});
it('should output correct markdown format', async () => {
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = actualContent.split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry, index) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
const roleIcon = role === 'user' ? '🧑‍💻' : '✨';
expect(entry).toBe(`${roleIcon} ## ${role.toUpperCase()}\n\n${text}`);
});
});
});
describe('serializeHistoryToMarkdown', () => {
it('should correctly serialize chat history to Markdown with icons', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown =
'🧑‍💻 ## USER\n\nHello\n\n---\n\n' +
'✨ ## MODEL\n\nHi there!\n\n---\n\n' +
'🧑‍💻 ## USER\n\nHow are you?';
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle empty history', () => {
const history: Content[] = [];
const result = serializeHistoryToMarkdown(history);
expect(result).toBe('');
});
it('should handle items with no text parts', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
## MODEL
---
🧑💻 ## USER
How are you?`;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should correctly serialize function calls and responses', () => {
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Please call a function.' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'my-function',
args: { arg1: 'value1' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'my-function',
response: { result: 'success' },
},
},
],
},
];
const expectedMarkdown = `🧑‍💻 ## USER
Please call a function.
---
## MODEL
**Tool Command**:
\`\`\`json
{
"name": "my-function",
"args": {
"arg1": "value1"
}
}
\`\`\`
---
🧑💻 ## USER
**Tool Response**:
\`\`\`json
{
"name": "my-function",
"response": {
"result": "success"
}
}
\`\`\``;
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle items with undefined role', () => {
const history: Array<Partial<Content>> = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ parts: [{ text: 'Hi there!' }] },
];
const expectedMarkdown = `🧑‍💻 ## USER
Hello
---
## MODEL
Hi there!`;
const result = serializeHistoryToMarkdown(history as Content[]);
expect(result).toBe(expectedMarkdown);
});
});
});