Compare commits

..

3 Commits

40 changed files with 363 additions and 1558 deletions
+5 -1
View File
@@ -105,7 +105,7 @@ Gemini CLI comes with the following built-in subagents:
slow. You can invoke it explicitly using `@generalist`.
- **Configuration:** Enabled by default.
### Browser Agent
### Browser Agent (experimental)
- **Name:** `browser_agent`
- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
@@ -115,6 +115,10 @@ Gemini CLI comes with the following built-in subagents:
the pricing table from this page," "Click the login button and enter my
credentials."
<!-- prettier-ignore -->
> [!NOTE]
> This is a preview feature currently under active development.
#### Prerequisites
The browser agent requires:
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"workspaces": [
"packages/*"
],
@@ -18117,7 +18117,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.19.0",
@@ -18246,7 +18246,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18394,7 +18394,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18674,7 +18674,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18689,7 +18689,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18720,7 +18720,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18752,7 +18752,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-preview.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-nightly.20260602.gcfcecebe8"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-preview.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-nightly.20260602.gcfcecebe8"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+2 -3
View File
@@ -1757,9 +1757,8 @@ describe('startInteractiveUI', () => {
// Verify all startup tasks were called
expect(getVersion).toHaveBeenCalledTimes(1);
// 6 cleanups: mouseEvents, lineWrapping, non-resumable session cleanup,
// instance.unmount, TTY check, and consolePatcher
expect(registerCleanup).toHaveBeenCalledTimes(6);
// 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check
expect(registerCleanup).toHaveBeenCalledTimes(5);
// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
-18
View File
@@ -194,17 +194,6 @@ export async function startInteractiveUI(
});
const cleanupUnmount = () => instance.unmount();
const cleanupNonResumableCurrentSession = async () => {
try {
await config
.getGeminiClient()
?.getChatRecordingService()
?.deleteCurrentSessionIfNotResumableAsync();
} catch (e: unknown) {
debugLogger.error('Error cleaning up non-resumable session:', e);
}
};
registerCleanup(cleanupNonResumableCurrentSession);
registerCleanup(cleanupUnmount);
const cleanupTtyCheck = setupTtyCheck();
@@ -223,13 +212,6 @@ export async function startInteractiveUI(
debugLogger.error('Error cleaning up console patcher:', e);
}
try {
removeCleanup(cleanupNonResumableCurrentSession);
await cleanupNonResumableCurrentSession();
} catch (e: unknown) {
debugLogger.error('Error removing non-resumable session cleanup:', e);
}
try {
removeCleanup(cleanupUnmount);
instance.unmount();
@@ -12,12 +12,7 @@ import { MessageType } from '../types.js';
describe('helpCommand', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
const action = helpCommand.action;
if (!action) {
throw new Error('Help command has no action');
}
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = createMockCommandContext({
@@ -28,13 +23,16 @@ describe('helpCommand', () => {
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.unstubAllEnvs();
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should add a help message to the UI history by default', async () => {
await action(mockContext, '');
it('should add a help message to the UI history', async () => {
if (!helpCommand.action) {
throw new Error('Help command has no action');
}
await helpCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -49,85 +47,4 @@ describe('helpCommand', () => {
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
expect(helpCommand.description).toBe('For help on gemini-cli');
});
describe('Antigravity installer commands help', () => {
it('should output macOS installation command on darwin platform', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
await action(mockContext, 'install antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on macOS, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
}),
);
});
it('should output Linux installation command on linux platform', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await action(mockContext, 'how do I install antigravity CLI');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on Linux, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
}),
);
});
it('should output Windows PowerShell installation command on win32 when PSModulePath is set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', 'C:\\some\\path');
await action(mockContext, 'how do I migrate to antigravity CLI');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on Windows (PowerShell), run the following command:\n\n'irm https://antigravity.google/cli/install.ps1 | iex'`,
}),
);
});
it('should output Windows CMD installation command on win32 when PSModulePath is not set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', '');
await action(mockContext, 'install antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on Windows (Command Prompt), run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd'`,
}),
);
});
it('should learn more message on unsupported platform', async () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' });
await action(mockContext, 'install antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started',
}),
);
});
it('should fall back to default help if query does not contain install or migrate', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
await action(mockContext, 'antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HELP,
}),
);
});
});
});
+1 -24
View File
@@ -6,36 +6,13 @@
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js';
export const helpCommand: SlashCommand = {
name: 'help',
kind: CommandKind.BUILT_IN,
description: 'For help on gemini-cli',
autoExecute: true,
action: async (context, args) => {
const lowerArgs = args?.toLowerCase() || '';
const hasAntigravity = lowerArgs.includes('antigravity');
const hasInstallOrMigrate =
lowerArgs.includes('install') || lowerArgs.includes('migrate');
if (hasAntigravity && hasInstallOrMigrate) {
const info = getAntigravityInstallInfo();
if (info) {
context.ui.addItem({
type: MessageType.INFO,
text: `To install the Antigravity CLI on ${info.platformName}, run the following command:\n\n'${info.installCmd}'`,
});
} else {
context.ui.addItem({
type: MessageType.INFO,
text: `Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started`,
});
}
return;
}
action: async (context) => {
const helpItem: Omit<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,
timestamp: new Date(),
@@ -3673,12 +3673,9 @@ describe('InputPrompt', () => {
});
it('should toggle paste expansion on double-click', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1000);
const id = '[Pasted Text: 10 lines]';
const largeText =
'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
const togglePasteExpansion = vi.fn();
const baseProps = props;
const TestWrapper = () => {
@@ -3717,9 +3714,8 @@ describe('InputPrompt', () => {
row: 0,
col: 2,
}),
togglePasteExpansion: vi.fn().mockImplementation((...args) => {
togglePasteExpansion(...args);
setIsExpanded((expanded) => !expanded);
togglePasteExpansion: vi.fn().mockImplementation(() => {
setIsExpanded(!isExpanded);
}),
getExpandedPasteAtLine: vi
.fn()
@@ -3750,8 +3746,7 @@ describe('InputPrompt', () => {
// 2. Verify expanded content is visible
await waitFor(() => {
expect(togglePasteExpansion).toHaveBeenCalledWith(id, 0, 2);
expect(stdout.lastFrame()).toContain('line10');
expect(stdout.lastFrame()).toMatchSnapshot();
});
// Simulate double-click to collapse
@@ -3760,8 +3755,6 @@ describe('InputPrompt', () => {
// 3. Verify placeholder is restored
await waitFor(() => {
expect(togglePasteExpansion).toHaveBeenCalledTimes(2);
expect(stdout.lastFrame()).toContain(id);
expect(stdout.lastFrame()).toMatchSnapshot();
});
@@ -161,6 +161,13 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
+2 -93
View File
@@ -10,14 +10,12 @@ import {
expect,
vi,
beforeEach,
afterEach,
type MockedFunction,
} from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useBanner, _clearSessionBannersForTest } from './useBanner.js';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
import chalk from 'chalk';
vi.mock('../../utils/persistentState.js', () => ({
persistentState: {
@@ -79,26 +77,10 @@ describe('useBanner', () => {
.update(defaultBannerData.defaultText)
.digest('hex')]: 5,
});
});
it('should not hide banner if show count exceeds max limit (Legacy format) if it contains an Antigravity announcement', async () => {
const antigravityBannerData = {
defaultText: 'Antigravity is coming to town!',
warningText: '',
};
const { result } = await renderHook(() => useBanner(defaultBannerData));
mockedPersistentStateGet.mockReturnValue({
[crypto
.createHash('sha256')
.update(antigravityBannerData.defaultText)
.digest('hex')]: 5,
});
const { result } = await renderHook(() => useBanner(antigravityBannerData));
expect(result.current.bannerText).toContain(
'Antigravity is coming to town!',
);
expect(result.current.bannerText).toBe('');
});
it('should increment the persistent count when banner is shown', async () => {
@@ -141,77 +123,4 @@ describe('useBanner', () => {
expect(result.current.bannerText).toBe('Line1\nLine2');
});
describe('Antigravity installation commands', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.unstubAllEnvs();
});
it('should append macOS & Linux install command when on darwin', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.sh | bash')}"`,
);
});
it('should append macOS & Linux install command when on linux', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.sh | bash')}"`,
);
});
it('should append Windows PowerShell install command when on win32 and PSModulePath is set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', 'C:\\some\\path');
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('irm https://antigravity.google/cli/install.ps1 | iex')}"`,
);
});
it('should append Windows CMD install command when on win32 and PSModulePath is not set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', '');
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd')}"`,
);
});
it('should not append install command if banner text does not contain Antigravity', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const data = { defaultText: 'Regular Banner', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe('Regular Banner');
});
it('should not append install command if process.platform is an unsupported platform', async () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' });
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe('Welcome to Antigravity!');
});
});
});
+2 -13
View File
@@ -7,8 +7,6 @@
import { useState, useEffect } from 'react';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
import chalk from 'chalk';
import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js';
const DEFAULT_MAX_BANNER_SHOWN_COUNT = 5;
@@ -43,19 +41,10 @@ export function useBanner(bannerData: BannerData) {
const currentBannerCount = bannerCounts[hashedText] || 0;
const showBanner =
activeText !== '' &&
(currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT ||
activeText.includes('Antigravity'));
activeText !== '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
const rawBannerText = showBanner ? activeText : '';
let bannerText = rawBannerText.replace(/\\n/g, '\n');
if (showBanner && activeText.includes('Antigravity')) {
const info = getAntigravityInstallInfo();
if (info) {
bannerText += `\n \nTo install run "${chalk.bold(info.installCmd)}"`;
}
}
const bannerText = rawBannerText.replace(/\\n/g, '\n');
useEffect(() => {
if (showBanner && activeText) {
@@ -1,72 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getAntigravityInstallInfo } from './antigravityUtils.js';
describe('antigravityUtils', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.unstubAllEnvs();
});
it('should return macOS installation info on darwin platform', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'macOS',
installCmd: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
});
});
it('should return Linux installation info on linux platform', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'Linux',
installCmd: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
});
});
it('should return Windows PowerShell installation info on win32 when PSModulePath is set', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', 'C:\\some\\path');
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'Windows (PowerShell)',
installCmd: 'irm https://antigravity.google/cli/install.ps1 | iex',
});
});
it('should return Windows CMD installation info on win32 when PSModulePath is not set', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', '');
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'Windows (Command Prompt)',
installCmd:
'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd',
});
});
it('should return null on unsupported platform', () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' });
const info = getAntigravityInstallInfo();
expect(info).toBeNull();
});
});
@@ -1,47 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
const ANTIGRAVITY_SH_INSTALL =
'curl -fsSL https://antigravity.google/cli/install.sh | bash';
export interface AntigravityInstallInfo {
platformName: string;
installCmd: string;
}
/**
* Gets the platform-specific installation details for the Antigravity CLI.
* Returns null if the current platform is unsupported.
*/
export function getAntigravityInstallInfo(): AntigravityInstallInfo | null {
if (process.platform === 'win32') {
if (process.env['PSModulePath']) {
return {
platformName: 'Windows (PowerShell)',
installCmd: 'irm https://antigravity.google/cli/install.ps1 | iex',
};
} else {
return {
platformName: 'Windows (Command Prompt)',
installCmd:
'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd',
};
}
} else if (process.platform === 'darwin') {
return {
platformName: 'macOS',
installCmd: ANTIGRAVITY_SH_INSTALL,
};
} else if (process.platform === 'linux') {
return {
platformName: 'Linux',
installCmd: ANTIGRAVITY_SH_INSTALL,
};
}
return null;
}
+142 -74
View File
@@ -9,6 +9,7 @@ import {
SessionSelector,
extractFirstUserMessage,
formatRelativeTime,
hasUserOrAssistantMessage,
SessionError,
convertSessionToHistoryFormats,
} from './sessionUtils.js';
@@ -511,80 +512,6 @@ describe('SessionSelector', () => {
expect(sessions[0].id).toBe(sessionIdWithUser);
});
it('should not list command-only sessions', async () => {
const commandOnlySessionId = randomUUID();
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const metadata = {
sessionId: commandOnlySessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:01:00.000Z',
};
const commandMessage = {
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:30.000Z',
};
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${commandOnlySessionId.slice(0, 8)}.jsonl`,
),
`${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n`,
);
const sessionSelector = new SessionSelector(storage);
const sessions = await sessionSelector.listSessions();
expect(sessions).toEqual([]);
});
it('should use the first non-command user message for display', async () => {
const sessionId = randomUUID();
const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
const metadata = {
sessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:02:00.000Z',
};
const commandMessage = {
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:30.000Z',
};
const realMessage = {
type: 'user',
content: 'Help me fix resume history',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
};
await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.jsonl`,
),
`${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n${JSON.stringify(realMessage)}\n`,
);
const sessionSelector = new SessionSelector(storage);
const sessions = await sessionSelector.listSessions();
expect(sessions).toHaveLength(1);
expect(sessions[0].firstUserMessage).toBe('Help me fix resume history');
expect(sessions[0].displayName).toBe('Help me fix resume history');
});
it('should list session with gemini message even without user message', async () => {
const sessionIdGeminiOnly = randomUUID();
@@ -854,6 +781,147 @@ describe('extractFirstUserMessage', () => {
});
});
describe('hasUserOrAssistantMessage', () => {
it('should return true when session has user message', () => {
const messages = [
{
type: 'user',
content: 'Hello',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(true);
});
it('should return true when session has gemini message', () => {
const messages = [
{
type: 'gemini',
content: 'Hello, how can I help?',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(true);
});
it('should return true when session has both user and gemini messages', () => {
const messages = [
{
type: 'user',
content: 'Hello',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'gemini',
content: 'Hi there!',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(true);
});
it('should return false when session only has info messages', () => {
const messages = [
{
type: 'info',
content: 'Session started',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(false);
});
it('should return false when session only has error messages', () => {
const messages = [
{
type: 'error',
content: 'An error occurred',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(false);
});
it('should return false when session only has warning messages', () => {
const messages = [
{
type: 'warning',
content: 'Warning message',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(false);
});
it('should return false when session only has system messages (mixed)', () => {
const messages = [
{
type: 'info',
content: 'Session started',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'error',
content: 'An error occurred',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
{
type: 'warning',
content: 'Warning message',
id: 'msg3',
timestamp: '2024-01-01T10:02:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(false);
});
it('should return true when session has user message among system messages', () => {
const messages = [
{
type: 'info',
content: 'Session started',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'user',
content: 'Hello',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
{
type: 'error',
content: 'An error occurred',
id: 'msg3',
timestamp: '2024-01-01T10:02:00.000Z',
},
] as MessageRecord[];
expect(hasUserOrAssistantMessage(messages)).toBe(true);
});
it('should return false for empty messages array', () => {
const messages: MessageRecord[] = [];
expect(hasUserOrAssistantMessage(messages)).toBe(false);
});
});
describe('formatRelativeTime', () => {
it('should format time correctly', () => {
const now = new Date();
+11 -4
View File
@@ -139,6 +139,15 @@ export interface SessionSelectionResult {
displayInfo: string;
}
/**
* Checks if a session has at least one user or assistant (gemini) message.
* Sessions with only system messages (info, error, warning) are considered empty.
* @param messages - The array of message records to check
* @returns true if the session has meaningful content
*/
export const hasUserOrAssistantMessage = (messages: MessageRecord[]): boolean =>
messages.some((msg) => msg.type === 'user' || msg.type === 'gemini');
/**
* Cleans and sanitizes message content for display by:
* - Converting newlines to spaces
@@ -278,10 +287,8 @@ export const getAllSessionFiles = async (
const lastUpdated =
content.lastUpdated || content.startTime || fallbackTimestamp;
// Skip sessions with no resumable conversation content, including
// startup-only, system-only, command-only, and internal-context-only
// sessions.
if (!content.hasResumableContent) {
// Skip sessions that only contain system messages (info, error, warning)
if (!content.hasUserOrAssistantMessage) {
return { fileName: file, sessionInfo: null };
}
+108
View File
@@ -267,5 +267,113 @@ describe('skillUtils', () => {
const exists = await fs.stat(skillDir).catch(() => null);
expect(exists).toBeNull();
});
it('should prevent path traversal in fallback uninstallation (e.g. sibling directories)', async () => {
const skillsDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(skillsDir, { recursive: true });
const siblingDir = path.join(tempDir, '.gemini/skills-attacker');
await fs.mkdir(siblingDir, { recursive: true });
// Attempt to uninstall the sibling directory using path traversal
const result = await uninstallSkill('../skills-attacker', 'user');
expect(result).toBeNull();
// Verify sibling directory is NOT deleted
const exists = await fs.stat(siblingDir).catch(() => null);
expect(exists).not.toBeNull();
});
it('should prevent path traversal in fallback uninstallation with dot or dot dot', async () => {
expect(await uninstallSkill('..', 'user')).toBeNull();
expect(await uninstallSkill('.', 'user')).toBeNull();
expect(await uninstallSkill('', 'user')).toBeNull();
});
});
describe('path traversal prevention', () => {
it('should throw error during installation if skill name is dot dot or dot', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: ..\ndescription: exploit\n---\nbody',
);
await expect(
installSkill(mockSkillSourceDir, 'workspace', undefined, () => {}),
).rejects.toThrow('Invalid skill name: Path traversal detected.');
});
it('should throw error during linking if skill name is dot dot or dot', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: ..\ndescription: exploit\n---\nbody',
);
await expect(
linkSkill(mockSkillSourceDir, 'workspace', () => {}),
).rejects.toThrow('Invalid skill name: Path traversal detected.');
});
it('should throw error during installation if subpath escapes temp directory', async () => {
const skillPath = path.join(projectRoot, 'weather-skill.skill');
const exists = await fs.stat(skillPath).catch(() => null);
if (!exists) return;
await expect(
installSkill(skillPath, 'workspace', '../escape', () => {}),
).rejects.toThrow('Invalid path: Directory traversal not allowed.');
});
it('should sanitize absolute path names and install them safely within the target directory', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: /tmp/exploit\ndescription: exploit\n---\nbody',
);
const installed = await installSkill(
mockSkillSourceDir,
'workspace',
undefined,
() => {},
);
expect(installed.length).toBe(1);
expect(installed[0].name).toBe('-tmp-exploit');
const destPath = installed[0].location;
const resolvedTarget = path.resolve(tempDir, '.gemini/skills');
expect(destPath.startsWith(resolvedTarget + path.sep)).toBe(true);
});
it('should sanitize traversal names with spaces and install them safely within the target directory', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: " ../../exploit "\ndescription: exploit\n---\nbody',
);
const installed = await installSkill(
mockSkillSourceDir,
'workspace',
undefined,
() => {},
);
expect(installed.length).toBe(1);
expect(installed[0].name).toBe(' ..-..-exploit ');
const destPath = installed[0].location;
const resolvedTarget = path.resolve(tempDir, '.gemini/skills');
expect(destPath.startsWith(resolvedTarget + path.sep)).toBe(true);
});
});
});
-306
View File
@@ -1,306 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SettingScope } from '../config/settings.js';
import type { SkillActionResult } from './skillSettings.js';
import {
Storage,
loadSkillsFromDir,
type SkillDefinition,
} from '@google/gemini-cli-core';
import { cloneFromGit } from '../config/extensions/github.js';
import extract from 'extract-zip';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
/**
* Shared logic for building the core skill action message while allowing the
* caller to control how each scope and its path are rendered (e.g., bolding or
* dimming).
*
* This function ONLY returns the description of what happened. It is up to the
* caller to append any interface-specific guidance (like "Use /skills reload"
* or "Restart required").
*/
export function renderSkillActionFeedback(
result: SkillActionResult,
formatScope: (label: string, path: string) => string,
): string {
const { skillName, action, status, error } = result;
if (status === 'error') {
return (
error ||
`An error occurred while attempting to ${action} skill "${skillName}".`
);
}
if (status === 'no-op') {
return `Skill "${skillName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`;
}
const isEnable = action === 'enable';
const actionVerb = isEnable ? 'enabled' : 'disabled';
const preposition = isEnable
? 'by removing it from the disabled list in'
: 'by adding it to the disabled list in';
const formatScopeItem = (s: { scope: SettingScope; path: string }) => {
const label =
s.scope === SettingScope.Workspace ? 'workspace' : s.scope.toLowerCase();
return formatScope(label, s.path);
};
const totalAffectedScopes = [
...result.modifiedScopes,
...result.alreadyInStateScopes,
];
if (totalAffectedScopes.length === 2) {
const s1 = formatScopeItem(totalAffectedScopes[0]);
const s2 = formatScopeItem(totalAffectedScopes[1]);
if (isEnable) {
return `Skill "${skillName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`;
} else {
return `Skill "${skillName}" is now disabled in both ${s1} and ${s2} settings.`;
}
}
const s = formatScopeItem(totalAffectedScopes[0]);
return `Skill "${skillName}" ${actionVerb} ${preposition} ${s} settings.`;
}
/**
* Central logic for installing a skill from a remote URL or local path.
*/
export async function installSkill(
source: string,
scope: 'user' | 'workspace',
subpath: string | undefined,
onLog: (msg: string) => void,
requestConsent: (
skills: SkillDefinition[],
targetDir: string,
) => Promise<boolean> = () => Promise.resolve(true),
): Promise<Array<{ name: string; location: string }>> {
let sourcePath = source;
let tempDirToClean: string | undefined = undefined;
const isGitUrl =
source.startsWith('git@') ||
source.startsWith('http://') ||
source.startsWith('https://');
const isSkillFile = source.toLowerCase().endsWith('.skill');
try {
if (isGitUrl) {
tempDirToClean = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-skill-'),
);
sourcePath = tempDirToClean;
onLog(`Cloning skill from ${source}...`);
// Reuse existing robust git cloning utility from extension manager.
await cloneFromGit(
{
source,
type: 'git',
},
tempDirToClean,
);
} else if (isSkillFile) {
tempDirToClean = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-skill-'),
);
sourcePath = tempDirToClean;
onLog(`Extracting skill from ${source}...`);
await extract(path.resolve(source), { dir: tempDirToClean });
}
// If a subpath is provided, resolve it against the cloned/local root.
if (subpath) {
sourcePath = path.join(sourcePath, subpath);
}
sourcePath = path.resolve(sourcePath);
// Quick security check to prevent directory traversal out of temp dir when cloning
if (
tempDirToClean &&
!sourcePath.startsWith(path.resolve(tempDirToClean))
) {
throw new Error('Invalid path: Directory traversal not allowed.');
}
onLog(`Searching for skills in ${sourcePath}...`);
const skills = await loadSkillsFromDir(sourcePath);
if (skills.length === 0) {
throw new Error(
`No valid skills found in ${source}${subpath ? ` at path "${subpath}"` : ''}. Ensure a SKILL.md file exists with valid frontmatter.`,
);
}
const workspaceDir = process.cwd();
const storage = new Storage(workspaceDir);
const targetDir =
scope === 'workspace'
? storage.getProjectSkillsDir()
: Storage.getUserSkillsDir();
if (!(await requestConsent(skills, targetDir))) {
throw new Error('Skill installation cancelled by user.');
}
await fs.mkdir(targetDir, { recursive: true });
const installedSkills: Array<{ name: string; location: string }> = [];
for (const skill of skills) {
const skillName = skill.name;
const skillDir = path.dirname(skill.location);
const destPath = path.join(targetDir, skillName);
const exists = await fs.stat(destPath).catch(() => null);
if (exists) {
onLog(`Skill "${skillName}" already exists. Overwriting...`);
await fs.rm(destPath, { recursive: true, force: true });
}
await fs.cp(skillDir, destPath, { recursive: true });
installedSkills.push({ name: skillName, location: destPath });
}
return installedSkills;
} finally {
if (tempDirToClean) {
await fs.rm(tempDirToClean, { recursive: true, force: true });
}
}
}
/**
* Central logic for linking a skill from a local path via symlink.
*/
export async function linkSkill(
source: string,
scope: 'user' | 'workspace',
onLog: (msg: string) => void,
requestConsent: (
skills: SkillDefinition[],
targetDir: string,
) => Promise<boolean> = () => Promise.resolve(true),
): Promise<Array<{ name: string; location: string }>> {
const sourcePath = path.resolve(source);
onLog(`Searching for skills in ${sourcePath}...`);
const skills = await loadSkillsFromDir(sourcePath);
if (skills.length === 0) {
throw new Error(
`No valid skills found in "${sourcePath}". Ensure a SKILL.md file exists with valid frontmatter.`,
);
}
// Check for internal name collisions
const seenNames = new Map<string, string>();
for (const skill of skills) {
if (seenNames.has(skill.name)) {
throw new Error(
`Duplicate skill name "${skill.name}" found at multiple locations:\n - ${seenNames.get(skill.name)}\n - ${skill.location}`,
);
}
seenNames.set(skill.name, skill.location);
}
const workspaceDir = process.cwd();
const storage = new Storage(workspaceDir);
const targetDir =
scope === 'workspace'
? storage.getProjectSkillsDir()
: Storage.getUserSkillsDir();
if (!(await requestConsent(skills, targetDir))) {
throw new Error('Skill linking cancelled by user.');
}
await fs.mkdir(targetDir, { recursive: true });
const linkedSkills: Array<{ name: string; location: string }> = [];
for (const skill of skills) {
const skillName = skill.name;
const skillSourceDir = path.dirname(skill.location);
const destPath = path.join(targetDir, skillName);
const exists = await fs.lstat(destPath).catch(() => null);
if (exists) {
onLog(
`Skill "${skillName}" already exists at destination. Overwriting...`,
);
await fs.rm(destPath, { recursive: true, force: true });
}
// Use 'junction' on Windows to avoid EPERM errors — junctions don't
// require elevated privileges or Developer Mode (fixes #24816)
await fs.symlink(
skillSourceDir,
destPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
linkedSkills.push({ name: skillName, location: destPath });
}
return linkedSkills;
}
/**
* Central logic for uninstalling a skill by name.
*/
export async function uninstallSkill(
name: string,
scope: 'user' | 'workspace',
): Promise<{ location: string } | null> {
const workspaceDir = process.cwd();
const storage = new Storage(workspaceDir);
const targetDir =
scope === 'workspace'
? storage.getProjectSkillsDir()
: Storage.getUserSkillsDir();
// Load all skills in the target directory to find the one with the matching name
const discoveredSkills = await loadSkillsFromDir(targetDir);
const skillToUninstall = discoveredSkills.find((s) => s.name === name);
if (!skillToUninstall) {
// Fallback: Check if a directory with the given name exists.
// This maintains backward compatibility for cases where the metadata might be missing or corrupted
// but the directory name matches the user's request.
const skillPath = path.resolve(targetDir, name);
// Security check: ensure the resolved path is within the target directory to prevent path traversal
if (!skillPath.startsWith(path.resolve(targetDir))) {
return null;
}
const exists = await fs.lstat(skillPath).catch(() => null);
if (!exists) {
return null;
}
await fs.rm(skillPath, { recursive: true, force: true });
return { location: skillPath };
}
const skillDir = path.dirname(skillToUninstall.location);
await fs.rm(skillDir, { recursive: true, force: true });
return { location: skillDir };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -15,7 +15,6 @@ import {
} from './codeAssist.js';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
import { ModelMappingContentGenerator } from '../core/modelMappingContentGenerator.js';
import { UserTierId } from './types.js';
// Mock dependencies
@@ -23,15 +22,11 @@ vi.mock('./oauth2.js');
vi.mock('./setup.js');
vi.mock('./server.js');
vi.mock('../core/loggingContentGenerator.js');
vi.mock('../core/modelMappingContentGenerator.js');
const mockedGetOauthClient = vi.mocked(getOauthClient);
const mockedSetupUser = vi.mocked(setupUser);
const MockedCodeAssistServer = vi.mocked(CodeAssistServer);
const MockedLoggingContentGenerator = vi.mocked(LoggingContentGenerator);
const MockedModelMappingContentGenerator = vi.mocked(
ModelMappingContentGenerator,
);
describe('codeAssist', () => {
beforeEach(() => {
@@ -183,47 +178,5 @@ describe('codeAssist', () => {
const server = getCodeAssistServer(mockConfig);
expect(server).toBeUndefined();
});
it('should unwrap and return the server if it is wrapped in a ModelMappingContentGenerator', () => {
const mockServer = new MockedCodeAssistServer({} as never, '', {});
const mockMapper = new MockedModelMappingContentGenerator(
{} as never,
{},
);
vi.spyOn(mockMapper, 'getWrapped').mockReturnValue(mockServer);
const mockConfig = {
getContentGenerator: () => mockMapper,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBe(mockServer);
expect(mockMapper.getWrapped).toHaveBeenCalled();
});
it('should recursively unwrap multiple layers of LoggingContentGenerator and ModelMappingContentGenerator', () => {
const mockServer = new MockedCodeAssistServer({} as never, '', {});
const mockLogger = new MockedLoggingContentGenerator(
{} as never,
{} as never,
);
const mockMapper = new MockedModelMappingContentGenerator(
{} as never,
{},
);
// Mapper wraps Logger wraps Server
vi.spyOn(mockMapper, 'getWrapped').mockReturnValue(mockLogger);
vi.spyOn(mockLogger, 'getWrapped').mockReturnValue(mockServer);
const mockConfig = {
getContentGenerator: () => mockMapper,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBe(mockServer);
expect(mockMapper.getWrapped).toHaveBeenCalled();
expect(mockLogger.getWrapped).toHaveBeenCalled();
});
});
});
+3 -10
View File
@@ -10,7 +10,6 @@ import { setupUser } from './setup.js';
import { CodeAssistServer, type HttpOptions } from './server.js';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
import { ModelMappingContentGenerator } from '../core/modelMappingContentGenerator.js';
export async function createCodeAssistContentGenerator(
httpOptions: HttpOptions,
@@ -44,15 +43,9 @@ export function getCodeAssistServer(
): CodeAssistServer | undefined {
let server = config.getContentGenerator();
// Recursively unwrap LoggingContentGenerator and ModelMappingContentGenerator
while (true) {
if (server instanceof LoggingContentGenerator) {
server = server.getWrapped();
} else if (server instanceof ModelMappingContentGenerator) {
server = server.getWrapped();
} else {
break;
}
// Unwrap LoggingContentGenerator if present
if (server instanceof LoggingContentGenerator) {
server = server.getWrapped();
}
if (!(server instanceof CodeAssistServer)) {
+3 -3
View File
@@ -4379,7 +4379,7 @@ describe('hasGemini35FlashGAAccess model setting', () => {
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3-flash-preview');
});
it('should set DEFAULT_GEMINI_FLASH_MODEL and PREVIEW_GEMINI_FLASH_MODEL to gemini-3.5-flash if hasGemini35FlashGAAccess returns true and authType is not USE_GEMINI', () => {
it('should set DEFAULT_GEMINI_FLASH_MODEL and PREVIEW_GEMINI_FLASH_MODEL to gemini-3-flash if hasGemini35FlashGAAccess returns true and authType is not USE_GEMINI', () => {
const config = new Config(baseParams);
config['contentGeneratorConfig'] = { authType: AuthType.LOGIN_WITH_GOOGLE };
@@ -4397,7 +4397,7 @@ describe('hasGemini35FlashGAAccess model setting', () => {
const result = config.hasGemini35FlashGAAccess();
expect(result).toBe(true);
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3-flash');
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3-flash');
});
});
+1 -1
View File
@@ -3566,7 +3566,7 @@ export class Config implements McpContext, AgentLoopContext {
if (authType === AuthType.USE_GEMINI) {
setFlashModels('gemini-3-flash-preview', 'gemini-3.5-flash');
} else {
setFlashModels('gemini-3.5-flash', 'gemini-3.5-flash');
setFlashModels('gemini-3-flash', 'gemini-3-flash');
}
} else {
setFlashModels('gemini-3-flash-preview', 'gemini-2.5-flash');
-4
View File
@@ -574,7 +574,3 @@ export function isActiveModel(
);
}
}
export const CCPA_AI_MODEL_MAPPINGS: Record<string, string> = {
[DEFAULT_GEMINI_3_5_FLASH_MODEL]: SECONDARY_GEMINI_3_5_FLASH_MODEL,
};
+2 -191
View File
@@ -18,13 +18,10 @@ import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from './loggingContentGenerator.js';
import { ModelMappingContentGenerator } from './modelMappingContentGenerator.js';
import { CCPA_AI_MODEL_MAPPINGS } from '../config/models.js';
import { loadApiKey } from './apiKeyCredentialStorage.js';
import { FakeContentGenerator } from './fakeContentGenerator.js';
import { RecordingContentGenerator } from './recordingContentGenerator.js';
import { resetVersionCache } from '../utils/version.js';
import type { LlmRole } from '../telemetry/llmRole.js';
vi.mock('../code_assist/codeAssist.js');
vi.mock('@google/genai');
@@ -39,14 +36,6 @@ const mockConfig = {
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getClientName: vi.fn().mockReturnValue(undefined),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(true),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
refreshUserQuotaIfStale: vi.fn().mockResolvedValue(undefined),
setLatestApiRequest: vi.fn(),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
isInteractive: vi.fn().mockReturnValue(false),
getExperiments: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
describe('getAuthTypeFromEnv', () => {
@@ -153,10 +142,7 @@ describe('createContentGenerator', () => {
);
expect(createCodeAssistContentGenerator).toHaveBeenCalled();
expect(generator).toEqual(
new LoggingContentGenerator(
new ModelMappingContentGenerator(mockGenerator, CCPA_AI_MODEL_MAPPINGS),
mockConfig,
),
new LoggingContentGenerator(mockGenerator, mockConfig),
);
});
@@ -173,10 +159,7 @@ describe('createContentGenerator', () => {
);
expect(createCodeAssistContentGenerator).toHaveBeenCalled();
expect(generator).toEqual(
new LoggingContentGenerator(
new ModelMappingContentGenerator(mockGenerator, CCPA_AI_MODEL_MAPPINGS),
mockConfig,
),
new LoggingContentGenerator(mockGenerator, mockConfig),
);
});
@@ -1112,178 +1095,6 @@ describe('createContentGenerator', () => {
}),
);
});
it('should not apply model mapping for Vertex AI', async () => {
const mockModels = {
generateContent: vi.fn().mockResolvedValue({}),
};
const mockGenerator = {
models: mockModels,
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
const generator = await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_VERTEX_AI,
vertexai: true,
},
mockConfig,
);
await generator.generateContent(
{
model: 'gemini-3-flash',
contents: [],
},
'prompt-id',
'user' as LlmRole,
);
expect(mockModels.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gemini-3-flash',
}),
'prompt-id',
'user',
);
});
it('should not apply model mapping for Gemini API', async () => {
const mockModels = {
generateContent: vi.fn().mockResolvedValue({}),
};
const mockGenerator = {
models: mockModels,
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
const generator = await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfig,
);
await generator.generateContent(
{
model: 'gemini-3-flash',
contents: [],
},
'prompt-id',
'user' as LlmRole,
);
expect(mockModels.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gemini-3-flash',
}),
'prompt-id',
'user',
);
});
it('should not apply model mapping for GATEWAY', async () => {
const mockModels = {
generateContent: vi.fn().mockResolvedValue({}),
};
const mockGenerator = {
models: mockModels,
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
const generator = await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.GATEWAY,
},
mockConfig,
);
await generator.generateContent(
{
model: 'gemini-3.5-flash',
contents: [],
},
'prompt-id',
'user' as LlmRole,
);
expect(mockModels.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gemini-3.5-flash',
}),
'prompt-id',
'user',
);
});
it('should apply model mapping for LOGIN_WITH_GOOGLE', async () => {
const mockInnerGenerator = {
generateContent: vi.fn().mockResolvedValue({}),
} as unknown as ContentGenerator;
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
mockInnerGenerator as never,
);
const generator = await createContentGenerator(
{
authType: AuthType.LOGIN_WITH_GOOGLE,
},
mockConfig,
);
await generator.generateContent(
{
model: 'gemini-3.5-flash',
contents: [],
},
'prompt-id',
'user' as LlmRole,
);
expect(mockInnerGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gemini-3-flash',
}),
'prompt-id',
'user',
);
});
it('should apply model mapping for COMPUTE_ADC', async () => {
const mockInnerGenerator = {
generateContent: vi.fn().mockResolvedValue({}),
} as unknown as ContentGenerator;
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(
mockInnerGenerator as never,
);
const generator = await createContentGenerator(
{
authType: AuthType.COMPUTE_ADC,
},
mockConfig,
);
await generator.generateContent(
{
model: 'gemini-3.5-flash',
contents: [],
},
'prompt-id',
'user' as LlmRole,
);
expect(mockInnerGenerator.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gemini-3-flash',
}),
'prompt-id',
'user',
);
});
});
describe('createContentGeneratorConfig', () => {
+5 -10
View File
@@ -30,8 +30,6 @@ import { determineSurface } from '../utils/surface.js';
import { RecordingContentGenerator } from './recordingContentGenerator.js';
import { getVersion, resolveModel } from '../../index.js';
import type { LlmRole } from '../telemetry/llmRole.js';
import { ModelMappingContentGenerator } from './modelMappingContentGenerator.js';
import { CCPA_AI_MODEL_MAPPINGS } from '../config/models.js';
/**
* Interface abstracting the core functionalities for generating content and counting tokens.
@@ -284,14 +282,11 @@ export async function createContentGenerator(
) {
const httpOptions = { headers: baseHeaders };
return new LoggingContentGenerator(
new ModelMappingContentGenerator(
await createCodeAssistContentGenerator(
httpOptions,
config.authType,
gcConfig,
sessionId,
),
CCPA_AI_MODEL_MAPPINGS,
await createCodeAssistContentGenerator(
httpOptions,
config.authType,
gcConfig,
sessionId,
),
gcConfig,
);
@@ -1,135 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { ModelMappingContentGenerator } from './modelMappingContentGenerator.js';
import type { ContentGenerator } from './contentGenerator.js';
import { LlmRole } from '../telemetry/llmRole.js';
import type { GenerateContentParameters } from '@google/genai';
describe('ModelMappingContentGenerator', () => {
const mockMappings = {
'gemini-3.5-flash': 'gemini-3-flash',
'gemini-pro': 'gemini-1.5-pro',
};
it('delegates userTier, userTierName, and paidTier properties', () => {
const mockWrapped = {
userTier: 'free',
userTierName: 'Free Tier',
paidTier: { id: 'paid' },
} as unknown as ContentGenerator;
const generator = new ModelMappingContentGenerator(
mockWrapped,
mockMappings,
);
expect(generator.userTier).toBe('free');
expect(generator.userTierName).toBe('Free Tier');
expect(generator.paidTier).toEqual({ id: 'paid' });
});
it('maps matching model without prefix', async () => {
const mockWrapped = {
generateContent: vi.fn().mockResolvedValue({}),
} as unknown as ContentGenerator;
const generator = new ModelMappingContentGenerator(
mockWrapped,
mockMappings,
);
const req = { model: 'gemini-3.5-flash', contents: [] };
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
{ model: 'gemini-3-flash', contents: [] },
'prompt-id',
LlmRole.MAIN,
);
});
it('maps matching model with models/ prefix', async () => {
const mockWrapped = {
generateContent: vi.fn().mockResolvedValue({}),
} as unknown as ContentGenerator;
const generator = new ModelMappingContentGenerator(
mockWrapped,
mockMappings,
);
const req = { model: 'models/gemini-3.5-flash', contents: [] };
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
{ model: 'models/gemini-3-flash', contents: [] },
'prompt-id',
LlmRole.MAIN,
);
});
it('leaves unmapped model unchanged', async () => {
const mockWrapped = {
generateContent: vi.fn().mockResolvedValue({}),
} as unknown as ContentGenerator;
const generator = new ModelMappingContentGenerator(
mockWrapped,
mockMappings,
);
const req = { model: 'unknown-model', contents: [] };
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
{ model: 'unknown-model', contents: [] },
'prompt-id',
LlmRole.MAIN,
);
});
it('leaves model with prefix unchanged if no match after normalization', async () => {
const mockWrapped = {
generateContent: vi.fn().mockResolvedValue({}),
} as unknown as ContentGenerator;
const generator = new ModelMappingContentGenerator(
mockWrapped,
mockMappings,
);
const req = { model: 'models/unknown-model', contents: [] };
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
{ model: 'models/unknown-model', contents: [] },
'prompt-id',
LlmRole.MAIN,
);
});
it('handles missing/undefined model property safely', async () => {
const mockWrapped = {
generateContent: vi.fn().mockResolvedValue({}),
} as unknown as ContentGenerator;
const generator = new ModelMappingContentGenerator(
mockWrapped,
mockMappings,
);
const req = { contents: [] } as unknown as GenerateContentParameters;
await generator.generateContent(req, 'prompt-id', LlmRole.MAIN);
expect(mockWrapped.generateContent).toHaveBeenCalledWith(
{ contents: [] },
'prompt-id',
LlmRole.MAIN,
);
});
});
@@ -1,88 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CountTokensResponse,
type GenerateContentResponse,
type GenerateContentParameters,
type CountTokensParameters,
type EmbedContentResponse,
type EmbedContentParameters,
} from '@google/genai';
import { type ContentGenerator } from './contentGenerator.js';
import type { LlmRole } from '../telemetry/llmRole.js';
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
import { normalizeModelId } from '../utils/modelUtils.js';
export class ModelMappingContentGenerator implements ContentGenerator {
constructor(
private readonly wrapped: ContentGenerator,
private readonly mappings: Record<string, string>,
) {}
getWrapped(): ContentGenerator {
return this.wrapped;
}
get userTier(): UserTierId | undefined {
return this.wrapped.userTier;
}
get userTierName(): string | undefined {
return this.wrapped.userTierName;
}
get paidTier(): GeminiUserTier | undefined {
return this.wrapped.paidTier;
}
private mapModel<T extends { model?: string }>(req: T): T {
if (req.model) {
const normalizedModel = normalizeModelId(req.model);
if (this.mappings[normalizedModel]) {
return {
...req,
model: req.model.startsWith('models/')
? `models/${this.mappings[normalizedModel]}`
: this.mappings[normalizedModel],
};
}
}
return req;
}
generateContent(
request: GenerateContentParameters,
userPromptId: string,
role: LlmRole,
): Promise<GenerateContentResponse> {
return this.wrapped.generateContent(
this.mapModel(request),
userPromptId,
role,
);
}
generateContentStream(
request: GenerateContentParameters,
userPromptId: string,
role: LlmRole,
): Promise<AsyncGenerator<GenerateContentResponse>> {
return this.wrapped.generateContentStream(
this.mapModel(request),
userPromptId,
role,
);
}
countTokens(request: CountTokensParameters): Promise<CountTokensResponse> {
return this.wrapped.countTokens(this.mapModel(request));
}
embedContent(request: EmbedContentParameters): Promise<EmbedContentResponse> {
return this.wrapped.embedContent(this.mapModel(request));
}
}
@@ -40,8 +40,6 @@ vi.mock('node:fs', async (importOriginal) => {
import {
ChatRecordingService,
hasResumableConversationContent,
isResumableMessageRecord,
loadConversationRecord,
type ConversationRecord,
type ToolCallRecord,
@@ -127,76 +125,6 @@ describe('ChatRecordingService', () => {
}
});
describe('isResumableMessageRecord', () => {
it('should treat malformed messages without content as non-resumable', () => {
const message = {
id: 'malformed-message',
timestamp: '2024-01-01T00:00:00.000Z',
type: 'user',
} as MessageRecord;
expect(() => isResumableMessageRecord(message)).not.toThrow();
expect(isResumableMessageRecord(message)).toBe(false);
});
it('should return false for command-only messages', () => {
const messages = [
{
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'user',
content: '?help',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];
expect(hasResumableConversationContent(messages)).toBe(false);
});
it('should return false for internal context-only messages', () => {
const messages = [
{
type: 'user',
content: '<session_context>previous state</session_context>',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'user',
content: '<hook_context>hook data</hook_context>',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];
expect(hasResumableConversationContent(messages)).toBe(false);
});
it('should return true for real user or assistant content', () => {
const messages = [
{
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'gemini',
content: 'I can help with that.',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];
expect(hasResumableConversationContent(messages)).toBe(true);
});
});
describe('initialize', () => {
it('should create a new session if none is provided', async () => {
await chatRecordingService.initialize();
@@ -910,49 +838,6 @@ describe('ChatRecordingService', () => {
});
});
describe('deleteCurrentSessionIfNotResumableAsync', () => {
it('should delete a startup-only session', async () => {
await chatRecordingService.initialize();
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();
expect(fs.existsSync(conversationFile!)).toBe(true);
await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();
expect(fs.existsSync(conversationFile!)).toBe(false);
});
it('should delete a command-only session', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: '/resume',
model: 'gemini-pro',
});
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();
await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();
expect(fs.existsSync(conversationFile!)).toBe(false);
});
it('should keep a session with a real user message', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: 'Help me debug this test',
model: 'gemini-pro',
});
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();
await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();
expect(fs.existsSync(conversationFile!)).toBe(true);
});
});
describe('recordDirectories', () => {
beforeEach(async () => {
await chatRecordingService.initialize();
@@ -23,8 +23,6 @@ import type {
import { debugLogger } from '../utils/debugLogger.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { HistoryTurn } from '../core/agentChatHistory.js';
import { partListUnionToString } from '../core/geminiRequest.js';
import { isIgnoredUserContent } from '../utils/sessionUtils.js';
import {
SESSION_FILE_PREFIX,
type TokensSummary,
@@ -100,36 +98,6 @@ function isTextPart(part: unknown): part is { text: string } {
return isStringProperty(part, 'text');
}
/**
* Returns true when a stored message represents conversation content worth
* surfacing in resume flows.
*/
export function isResumableMessageRecord(message: MessageRecord): boolean {
const contentString = message.content
? partListUnionToString(message.content)
: '';
if (message.type === 'user') {
return !isIgnoredUserContent(contentString.trim());
}
if (message.type === 'gemini') {
return (
contentString.trim().length > 0 ||
(message.toolCalls?.length ?? 0) > 0 ||
(message.thoughts?.length ?? 0) > 0
);
}
return false;
}
export function hasResumableConversationContent(
messages: readonly MessageRecord[],
): boolean {
return messages.some((message) => isResumableMessageRecord(message));
}
export async function loadConversationRecord(
filePath: string,
options?: LoadConversationOptions,
@@ -138,7 +106,7 @@ export async function loadConversationRecord(
messageCount?: number;
userMessageCount?: number;
firstUserMessage?: string;
hasResumableContent?: boolean;
hasUserOrAssistantMessage?: boolean;
memoryScratchpadIsStale?: boolean;
})
| null
@@ -159,7 +127,7 @@ export async function loadConversationRecord(
const messageIds: string[] = [];
const messageKinds = new Map<
string,
{ isUser: boolean; isResumable: boolean }
{ isUser: boolean; isUserOrAssistant: boolean }
>();
let isTrackingMemoryScratchpadFreshness = false;
let memoryScratchpadIsStale = false;
@@ -206,18 +174,19 @@ export async function loadConversationRecord(
}
const id = record.id;
const isUser = hasProperty(record, 'type') && record.type === 'user';
const isResumable = isResumableMessageRecord(record);
const isUserOrAssistant =
hasProperty(record, 'type') &&
(record.type === 'user' || record.type === 'gemini');
// Track message count and first user message
if (options?.metadataOnly) {
messageIds.push(id);
messageKinds.set(id, { isUser, isResumable });
messageKinds.set(id, { isUser, isUserOrAssistant });
}
if (
!firstUserMessageStr &&
isUser &&
hasProperty(record, 'content') &&
record['content'] &&
isResumable
record['content']
) {
// Basic extraction of first user message for display
const rawContent = record['content'];
@@ -261,14 +230,12 @@ export async function loadConversationRecord(
if (isMessageRecord(msg)) {
const id = msg.id;
const isUser = msg.type === 'user';
const isResumable = isResumableMessageRecord(msg);
const isUserOrAssistant =
msg.type === 'user' || msg.type === 'gemini';
if (options?.metadataOnly) {
messageIds.push(id);
messageKinds.set(id, {
isUser,
isResumable,
});
messageKinds.set(id, { isUser, isUserOrAssistant });
} else {
messagesMap.set(id, msg);
}
@@ -276,7 +243,6 @@ export async function loadConversationRecord(
if (
!firstUserMessageStr &&
isUser &&
isResumable &&
msg.content &&
(Array.isArray(msg.content) ||
typeof msg.content === 'string')
@@ -308,14 +274,12 @@ export async function loadConversationRecord(
if (isMessageRecord(msg)) {
const id = msg.id;
const isUser = msg.type === 'user';
const isResumable = isResumableMessageRecord(msg);
const isUserOrAssistant =
msg.type === 'user' || msg.type === 'gemini';
if (options?.metadataOnly) {
messageIds.push(id);
messageKinds.set(id, {
isUser,
isResumable,
});
messageKinds.set(id, { isUser, isUserOrAssistant });
} else {
messagesMap.set(id, msg);
}
@@ -323,7 +287,6 @@ export async function loadConversationRecord(
if (
!firstUserMessageStr &&
isUser &&
isResumable &&
msg.content &&
(Array.isArray(msg.content) ||
typeof msg.content === 'string')
@@ -351,10 +314,7 @@ export async function loadConversationRecord(
const loadedMessages = Array.from(messagesMap.values());
const metadataFirstUserMessage =
loadedMessages.find(
(message) =>
message.type === 'user' && isResumableMessageRecord(message),
) ?? null;
loadedMessages.find((message) => message.type === 'user') ?? null;
let fallbackFirstUserMessage = firstUserMessageStr;
if (!fallbackFirstUserMessage && metadataFirstUserMessage) {
const rawContent = metadataFirstUserMessage.content;
@@ -369,9 +329,9 @@ export async function loadConversationRecord(
const userMessageCount = options?.metadataOnly
? Array.from(messageKinds.values()).filter((m) => m.isUser).length
: loadedMessages.filter((m) => m.type === 'user').length;
const hasResumableContent = options?.metadataOnly
? Array.from(messageKinds.values()).some((m) => m.isResumable)
: hasResumableConversationContent(loadedMessages);
const hasUserOrAssistant = options?.metadataOnly
? Array.from(messageKinds.values()).some((m) => m.isUserOrAssistant)
: loadedMessages.some((m) => m.type === 'user' || m.type === 'gemini');
return {
sessionId: metadata.sessionId,
@@ -391,7 +351,7 @@ export async function loadConversationRecord(
? memoryScratchpadIsStale
: undefined,
firstUserMessage: fallbackFirstUserMessage,
hasResumableContent,
hasUserOrAssistantMessage: hasUserOrAssistant,
};
} catch (error) {
debugLogger.error('Error loading conversation record from JSONL:', error);
@@ -831,23 +791,6 @@ export class ChatRecordingService {
}
}
/**
* Deletes the current session only if it has no resumable conversation
* content. This removes abandoned startup-only sessions while preserving any
* session with a real user prompt, model response, or tool activity.
*/
async deleteCurrentSessionIfNotResumableAsync(): Promise<void> {
if (!this.conversationFile || !this.cachedConversation) {
return;
}
if (hasResumableConversationContent(this.cachedConversation.messages)) {
return;
}
await this.deleteCurrentSessionAsync();
}
/**
* Rewinds the conversation to the state just before the specified message ID.
* All messages from (and including) the specified ID onwards are removed.
@@ -970,7 +913,7 @@ async function parseLegacyRecordFallback(
messageCount?: number;
userMessageCount?: number;
firstUserMessage?: string;
hasResumableContent?: boolean;
hasUserOrAssistantMessage?: boolean;
})
| null
> {
@@ -986,7 +929,7 @@ async function parseLegacyRecordFallback(
if (options?.metadataOnly) {
let fallbackFirstUserMessageStr: string | undefined;
const firstUserMessage = legacyRecord.messages?.find(
(m) => m.type === 'user' && isResumableMessageRecord(m),
(m) => m.type === 'user',
);
if (firstUserMessage) {
const rawContent = firstUserMessage.content;
@@ -1005,18 +948,20 @@ async function parseLegacyRecordFallback(
userMessageCount:
legacyRecord.messages?.filter((m) => m.type === 'user').length || 0,
firstUserMessage: fallbackFirstUserMessageStr,
hasResumableContent:
legacyRecord.messages?.some((m) => isResumableMessageRecord(m)) ||
false,
hasUserOrAssistantMessage:
legacyRecord.messages?.some(
(m) => m.type === 'user' || m.type === 'gemini',
) || false,
};
}
return {
...legacyRecord,
userMessageCount:
legacyRecord.messages?.filter((m) => m.type === 'user').length || 0,
hasResumableContent:
legacyRecord.messages?.some((m) => isResumableMessageRecord(m)) ||
false,
hasUserOrAssistantMessage:
legacyRecord.messages?.some(
(m) => m.type === 'user' || m.type === 'gemini',
) || false,
};
}
} catch {
@@ -1,58 +0,0 @@
---
name: antigravity-support
description: Use when the user asks questions, seeks help, or requests instructions related to installing, setting up, or migrating to Antigravity CLI. This skill provides the latest up to date details, requirements, and commands sourced from the official Antigravity CLI documentation.
---
# Antigravity CLI Support
This skill provides up-to-date information on how to install, configure, use, and migrate to Antigravity CLI, sourced from the official documentation at https://antigravity.google/docs/cli-getting-started.
## What is Antigravity CLI?
Antigravity CLI is a next-generation terminal interface for collaborating with autonomous agents on local codebases. It is designed to be highly interactive and agent-driven, launching a Terminal User Interface (TUI) to coordinate code generation, reasoning, and workspace tasks.
Key Features:
- **Autonomous Agent Collaboration:** Work directly with agents within your terminal.
- **Interactive TUI:** A full terminal user interface designed for agent workflows.
- **Workspace Integration:** Deep understanding of your local workspace structure and context.
## Installation
To install the Antigravity CLI on your machine:
### macOS / Linux (Fast-Path Script)
Run the following standard curl command in your terminal:
```bash
curl -fsSL https://antigravity.google/cli/install.sh | bash
```
This script downloads, verifies, and installs the latest version of Antigravity, and automatically registers the `agy` binary in your PATH.
### Windows (PowerShell)
For Windows environments, install via the official PowerShell setup command:
```powershell
irm https://antigravity.google/cli/install.ps1 | iex
```
## Initial Setup & Configuration
Once installed, navigate to any project or workspace directory and run:
```bash
agy
```
This command starts the Antigravity CLI. The first time you launch it, the interactive TUI will guide you through:
1. **Workspace Trust Verification:** Confirming trust for the workspace folder to allow secure local command execution and file edits.
2. **Visual Theme Configuration:** Setting up your preferred interactive terminal aesthetic and layout.
3. **Rendering Modes:** Tailoring TUI performance and drawing behaviors to your terminal capabilities.
## How to Migrate to Antigravity CLI
If you are transitioning or migrating from another tool (such as Gemini CLI) to Antigravity CLI, follow these steps:
1. **Check Requirements:** Ensure your local environment meets standard requirements (e.g., node, git, shell access) and is running a compatible operating system (macOS, Linux, or Windows).
2. **Install Antigravity:** Run the installation script above to make the `agy` command globally available.
3. **Verify Installation:** Test the installation by running `agy --version` or launching `agy` in an empty or sample directory.
4. **Transition Workspaces:** Run `agy` directly inside your project workspace root. The initial setup assistant will guide you to import or configure trust policies, similar to those you might have used previously.
## Official Resources and Learning More
If you need more details or have advanced configuration/migration needs, please visit the official documentation:
- **Official Documentation:** https://antigravity.google/docs/cli-getting-started
@@ -271,19 +271,4 @@ description: Test sanitization
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe('gke-prs-troubleshooter');
});
it('should load real built-in antigravity-support skill successfully', async () => {
const { fileURLToPath } = await import('node:url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const builtinDir = path.resolve(__dirname, 'builtin');
const skills = await loadSkillsFromDir(builtinDir);
const antigravitySkill = skills.find(
(s) => s.name === 'antigravity-support',
);
expect(antigravitySkill).toBeDefined();
expect(antigravitySkill!.description).toContain('Antigravity CLI');
expect(antigravitySkill!.body).toContain(
'https://antigravity.google/docs/cli-getting-started',
);
});
});
+5 -11
View File
@@ -1244,15 +1244,9 @@ describe('mcp-client', () => {
await client.disconnect();
expect(mockedClient.close).toHaveBeenCalledOnce();
expect(mockedToolRegistry.removeMcpToolsByServer).toHaveBeenCalledWith(
'test-server',
);
expect(mockedPromptRegistry.removePromptsByServer).toHaveBeenCalledWith(
'test-server',
);
expect(resourceRegistry.removeResourcesByServer).toHaveBeenCalledWith(
'test-server',
);
expect(mockedToolRegistry.removeMcpToolsByServer).toHaveBeenCalledOnce();
expect(mockedPromptRegistry.removePromptsByServer).toHaveBeenCalledOnce();
expect(resourceRegistry.removeResourcesByServer).toHaveBeenCalledOnce();
});
});
@@ -1576,8 +1570,8 @@ describe('mcp-client', () => {
// Trigger notification - should fail internally but catch the error
await notificationCallback();
// Should NOT try to remove tools because discovery failed (atomic refresh)
expect(mockedToolRegistry.removeMcpToolsByServer).not.toHaveBeenCalled();
// Should try to remove tools
expect(mockedToolRegistry.removeMcpToolsByServer).toHaveBeenCalled();
// Should NOT emit success feedback
expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith(
-1
View File
@@ -1404,7 +1404,6 @@ export async function discoverTools(
error,
mcpServerName,
);
throw error;
}
return [];
}
+6 -11
View File
@@ -94,16 +94,6 @@ function ensurePartArray(content: PartListUnion): Part[] {
return [content];
}
export function isIgnoredUserContent(trimmedContent: string): boolean {
return (
trimmedContent.length === 0 ||
trimmedContent.startsWith('/') ||
trimmedContent.startsWith('?') ||
trimmedContent.startsWith('<session_context>') ||
trimmedContent.startsWith('<hook_context>')
);
}
/**
* Converts session/conversation data into Gemini client history formats.
*/
@@ -120,7 +110,12 @@ export function convertSessionToClientHistory(
if (msg.type === 'user') {
const contentString = partListUnionToString(msg.content);
const trimmedContent = contentString.trim();
if (isIgnoredUserContent(trimmedContent)) {
if (
trimmedContent.startsWith('/') ||
trimmedContent.startsWith('?') ||
trimmedContent.startsWith('<session_context>') ||
trimmedContent.startsWith('<hook_context>')
) {
continue;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.47.0-preview.0",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {