Compare commits

..

2 Commits

25 changed files with 485 additions and 481 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ gemini --delete-session 1
## How to rewind time (Undo mistakes)
Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow.
Gemini CLI's **Rewind** feature is like Undo for your workflow.
### Scenario: Triggering rewind
+5 -4
View File
@@ -499,12 +499,13 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
These shortcuts apply directly to the input prompt for text manipulation.
- **Undo:**
- **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
in the input prompt.
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
**Alt+z** (Linux) to undo the last action in the input prompt.
- **Redo:**
- **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
last undone action in the input prompt.
- **Keyboard shortcut:** Press **Ctrl+y** (Windows), **Shift+Cmd+Z** (macOS),
or **Shift+Alt+Z** (Linux) to redo the last undone action in the input
prompt.
## At commands (`@`)
+2 -2
View File
@@ -39,8 +39,8 @@ available combinations.
| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`<br />`Alt+Delete`<br />`Alt+D` |
| `edit.deleteLeft` | Delete the character to the left. | `Backspace`<br />`Ctrl+H` |
| `edit.deleteRight` | Delete the character to the right. | `Delete`<br />`Ctrl+D` |
| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`<br />`Alt+Z` |
| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`<br />`Shift+Cmd/Win+Z`<br />`Alt+Shift+Z` |
| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z` (Windows)<br />`Cmd+Z` (macOS)<br />`Alt+Z` (Linux) |
| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Y` (Windows)<br />`Ctrl+Shift+Z`<br />`Shift+Cmd+Z`<br />`Alt+Shift+Z` |
#### Scrolling
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"workspaces": [
"packages/*"
],
@@ -18077,7 +18077,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -18206,7 +18206,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18354,7 +18354,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18665,7 +18665,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18680,7 +18680,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18711,7 +18711,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18743,7 +18743,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"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.41.0-preview.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
},
"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.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"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.41.0-preview.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -41,6 +41,7 @@ import {
getTransformedImagePath,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
import { type Key } from '../../hooks/useKeypress.js';
import { escapePath } from '@google/gemini-cli-core';
const defaultVisualLayout: VisualLayout = {
@@ -1799,6 +1800,180 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).text).toBe('');
});
it('should only handle Undo if there is something to undo', async () => {
const { result } = await renderHook(() => useTextBuffer({ viewport }));
// Platform-specific undo key
const undoKey: Key =
process.platform === 'win32'
? {
name: 'z',
ctrl: true,
shift: false,
alt: false,
cmd: false,
insertable: false,
sequence: '\x1a',
}
: process.platform === 'darwin'
? {
name: 'z',
ctrl: false,
shift: false,
alt: false,
cmd: true,
insertable: false,
sequence: '\u001b[122;D',
}
: {
name: 'z',
ctrl: false,
shift: false,
alt: true,
cmd: false,
insertable: false,
sequence: '\u001bz',
};
// 1. Initial state: nothing to undo
let handled = true;
act(() => {
handled = result.current.handleInput(undoKey);
});
expect(handled).toBe(false);
// 2. Insert something
act(() => {
result.current.handleInput({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'a',
});
});
expect(getBufferState(result).text).toBe('a');
// 3. Now undo should work
act(() => {
handled = result.current.handleInput(undoKey);
});
expect(handled).toBe(true);
expect(getBufferState(result).text).toBe('');
// 4. Undo again: nothing left to undo
act(() => {
handled = result.current.handleInput(undoKey);
});
expect(handled).toBe(false);
});
it('should only handle Redo if there is something to redo', async () => {
const { result } = await renderHook(() => useTextBuffer({ viewport }));
// Platform-specific redo key (first in list)
const redoKey: Key =
process.platform === 'win32'
? {
name: 'y',
ctrl: true,
shift: false,
alt: false,
cmd: false,
insertable: false,
sequence: '\x19',
}
: process.platform === 'darwin'
? {
name: 'z',
ctrl: false,
shift: true,
alt: false,
cmd: true,
insertable: false,
sequence: '\u001b[122;2D',
}
: {
name: 'z',
ctrl: false,
shift: true,
alt: true,
cmd: false,
insertable: false,
sequence: '\u001bZ',
};
const undoKey: Key =
process.platform === 'win32'
? {
name: 'z',
ctrl: true,
shift: false,
alt: false,
cmd: false,
insertable: false,
sequence: '\x1a',
}
: process.platform === 'darwin'
? {
name: 'z',
ctrl: false,
shift: false,
alt: false,
cmd: true,
insertable: false,
sequence: '\u001b[122;D',
}
: {
name: 'z',
ctrl: false,
shift: false,
alt: true,
cmd: false,
insertable: false,
sequence: '\u001bz',
};
// 1. Initial state: nothing to redo
let handled = true;
act(() => {
handled = result.current.handleInput(redoKey);
});
expect(handled).toBe(false);
// 2. Insert and Undo
act(() => {
result.current.handleInput({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'a',
});
});
act(() => {
result.current.handleInput(undoKey);
});
expect(getBufferState(result).text).toBe('');
// 3. Now redo should work
act(() => {
handled = result.current.handleInput(redoKey);
});
expect(handled).toBe(true);
expect(getBufferState(result).text).toBe('a');
// 4. Redo again: nothing left to redo
act(() => {
handled = result.current.handleInput(redoKey);
});
expect(handled).toBe(false);
});
it('should handle multiple delete characters in one input', async () => {
const { result } = await renderHook(() =>
useTextBuffer({
@@ -2889,6 +2889,8 @@ export function useTextBuffer({
transformationsByLine,
pastedContent,
expandedPaste,
undoStack,
redoStack,
} = state;
const text = useMemo(() => lines.join('\n'), [lines]);
@@ -3454,10 +3456,16 @@ export function useTextBuffer({
return true;
}
if (keyMatchers[Command.UNDO](key)) {
if (undoStack.length === 0) {
return false;
}
undo();
return true;
}
if (keyMatchers[Command.REDO](key)) {
if (redoStack.length === 0) {
return false;
}
redo();
return true;
}
@@ -3486,6 +3494,8 @@ export function useTextBuffer({
visualCursor,
visualLines,
keyMatchers,
undoStack.length,
redoStack.length,
],
);
@@ -108,6 +108,36 @@ describe('keyBindings config', () => {
}
});
it('should have platform-specific UNDO bindings', () => {
const undoBindings = defaultKeyBindingConfig.get(Command.UNDO);
if (process.platform === 'win32') {
expect(undoBindings?.[0].name).toBe('z');
expect(undoBindings?.[0].ctrl).toBe(true);
} else if (process.platform === 'darwin') {
expect(undoBindings?.[0].name).toBe('z');
expect(undoBindings?.[0].cmd).toBe(true);
} else {
expect(undoBindings?.[0].name).toBe('z');
expect(undoBindings?.[0].alt).toBe(true);
}
});
it('should have platform-specific REDO bindings', () => {
const redoBindings = defaultKeyBindingConfig.get(Command.REDO);
if (process.platform === 'win32') {
expect(redoBindings?.[0].name).toBe('y');
expect(redoBindings?.[0].ctrl).toBe(true);
} else if (process.platform === 'darwin') {
expect(redoBindings?.[0].name).toBe('z');
expect(redoBindings?.[0].shift).toBe(true);
expect(redoBindings?.[0].cmd).toBe(true);
} else {
expect(redoBindings?.[0].name).toBe('z');
expect(redoBindings?.[0].shift).toBe(true);
expect(redoBindings?.[0].alt).toBe(true);
}
});
describe('command metadata', () => {
const commandValues = Object.values(Command);
+13 -6
View File
@@ -312,14 +312,21 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
Command.DELETE_CHAR_RIGHT,
[new KeyBinding('delete'), new KeyBinding('ctrl+d')],
],
[Command.UNDO, [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]],
[
Command.UNDO,
process.platform === 'win32'
? [new KeyBinding('ctrl+z'), new KeyBinding('alt+z')]
: process.platform === 'darwin'
? [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]
: [new KeyBinding('alt+z'), new KeyBinding('cmd+z')],
],
[
Command.REDO,
[
new KeyBinding('ctrl+shift+z'),
new KeyBinding('cmd+shift+z'),
new KeyBinding('alt+shift+z'),
],
process.platform === 'win32'
? [new KeyBinding('ctrl+y'), new KeyBinding('ctrl+shift+z')]
: process.platform === 'darwin'
? [new KeyBinding('cmd+shift+z'), new KeyBinding('alt+shift+z')]
: [new KeyBinding('alt+shift+z'), new KeyBinding('cmd+shift+z')],
],
// Scrolling
@@ -15,6 +15,25 @@ const debugLogger = vi.hoisted(() => ({
vi.mock('@google/gemini-cli-core', () => ({
getPackageJson,
debugLogger,
ReleaseChannel: {
NIGHTLY: 'nightly',
PREVIEW: 'preview',
STABLE: 'stable',
},
getChannelFromVersion: (version: string) => {
if (!version || version.includes('nightly')) {
return 'nightly';
}
if (version.includes('preview')) {
return 'preview';
}
return 'stable';
},
RELEASE_CHANNEL_STABILITY: {
nightly: 0,
preview: 1,
stable: 2,
},
}));
const latestVersion = vi.hoisted(() => vi.fn());
@@ -152,4 +171,68 @@ describe('checkForUpdates', () => {
expect(result?.update.latest).toBe('1.2.3-nightly.2');
});
});
describe('channel stability', () => {
it('should NOT offer nightly update to a stable user even if tagged as latest', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
// latest points to a nightly that is semver-greater
latestVersion.mockResolvedValue('1.1.0-nightly.1');
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
});
it('should NOT offer preview update to a stable user even if tagged as latest', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
// latest points to a preview that is semver-greater
latestVersion.mockResolvedValue('1.1.0-preview.1');
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
});
it('should offer stable update to a stable user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
latestVersion.mockResolvedValue('1.1.0');
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
it('should offer stable update to a nightly user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-nightly.1',
});
latestVersion.mockImplementation(async (name, options) => {
if (options?.version === 'nightly') {
return '1.0.0-nightly.1'; // No nightly update
}
return '1.1.0'; // Stable update available
});
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
it('should offer stable update to a preview user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-preview.1',
});
latestVersion.mockResolvedValue('1.1.0');
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
});
});
+21 -2
View File
@@ -6,7 +6,12 @@
import latestVersion from 'latest-version';
import semver from 'semver';
import { getPackageJson, debugLogger } from '@google/gemini-cli-core';
import {
getPackageJson,
debugLogger,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -65,6 +70,7 @@ export async function checkForUpdates(
}
const { name, version: currentVersion } = packageJson;
const currentChannel = getChannelFromVersion(currentVersion);
const isNightly = currentVersion.includes('nightly');
if (isNightly) {
@@ -90,8 +96,21 @@ export async function checkForUpdates(
}
} else {
const latestUpdate = await latestVersion(name);
if (!latestUpdate) {
return null;
}
if (latestUpdate && semver.gt(latestUpdate, currentVersion)) {
const targetChannel = getChannelFromVersion(latestUpdate);
// Only offer updates that are as stable or more stable than the current version
if (
RELEASE_CHANNEL_STABILITY[targetChannel] <
RELEASE_CHANNEL_STABILITY[currentChannel]
) {
return null;
}
if (semver.gt(latestUpdate, currentVersion)) {
const message = `Gemini CLI update available! ${currentVersion}${latestUpdate}`;
const type = semver.diff(latestUpdate, currentVersion) || undefined;
return {
@@ -334,7 +334,8 @@ describe('handleAutoUpdate', () => {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
latest: '2.0.0-nightly',
current: '1.0.0-nightly.0',
latest: '2.0.0-nightly.1',
},
};
mockGetInstallationInfo.mockReturnValue({
@@ -356,6 +357,26 @@ describe('handleAutoUpdate', () => {
);
});
it('should NOT update if target is less stable than current (defense-in-depth)', async () => {
mockUpdateInfo = {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
current: '1.0.0',
latest: '1.1.0-nightly.1',
},
};
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
isGlobal: false,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should emit "update-success" when the update process succeeds', async () => {
await new Promise<void>((resolve) => {
mockGetInstallationInfo.mockReturnValue({
+24 -1
View File
@@ -11,7 +11,11 @@ import { updateEventEmitter } from './updateEventEmitter.js';
import { MessageType, type HistoryItem } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import type { spawn } from 'node:child_process';
import { debugLogger } from '@google/gemini-cli-core';
import {
debugLogger,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
let _updateInProgress = false;
@@ -122,6 +126,25 @@ export function handleAutoUpdate(
return;
}
const currentVersion = info.update.current;
if (!currentVersion) {
debugLogger.warn(
'Update check: current version is missing. Skipping automatic update for safety.',
);
return;
}
const currentChannel = getChannelFromVersion(currentVersion);
const targetChannel = getChannelFromVersion(info.update.latest);
// Defense-in-depth: prevent updates to a less stable channel
if (
RELEASE_CHANNEL_STABILITY[targetChannel] <
RELEASE_CHANNEL_STABILITY[currentChannel]
) {
return;
}
const isNightly = info.update.latest.includes('nightly');
const updateCommand = installationInfo.updateCommand.replace(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -19,7 +19,6 @@ import {
import type { Config } from '../config/config.js';
import * as sdk from './sdk.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js';
vi.mock('@opentelemetry/api-logs');
vi.mock('./sdk.js');
@@ -145,174 +144,4 @@ describe('conseca-logger', () => {
expect(mockLogger.emit).not.toHaveBeenCalled();
});
it('should omit user_prompt/trusted_content/policy from OTEL when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaPolicyGenerationEvent(
'sensitive prompt',
'sensitive content',
'sensitive policy',
);
logConsecaPolicyGeneration(configNoPrompts, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBeUndefined();
expect(attrs['trusted_content']).toBeUndefined();
expect(attrs['policy']).toBeUndefined();
expect(attrs['event.name']).toBe(EVENT_CONSECA_POLICY_GENERATION);
});
it('should omit user_prompt/trusted_content/policy from Clearcut when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaPolicyGenerationEvent(
'sensitive prompt',
'sensitive content',
'sensitive policy',
'some error',
);
logConsecaPolicyGeneration(configNoPrompts, event);
expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith(
expect.anything(),
[
{
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
value: 'some error',
},
],
);
});
it('should include user_prompt/trusted_content/policy in OTEL when logPrompts is enabled', () => {
const event = new ConsecaPolicyGenerationEvent(
'visible prompt',
'visible content',
'visible policy',
);
logConsecaPolicyGeneration(mockConfig, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBe('visible prompt');
expect(attrs['trusted_content']).toBe('visible content');
expect(attrs['policy']).toBe('visible policy');
});
it('should omit sensitive fields from verdict OTEL when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaVerdictEvent(
'sensitive prompt',
'sensitive policy',
'sensitive tool call',
'allow',
'sensitive rationale',
);
logConsecaVerdict(configNoPrompts, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBeUndefined();
expect(attrs['policy']).toBeUndefined();
expect(attrs['tool_call']).toBeUndefined();
expect(attrs['verdict_rationale']).toBeUndefined();
// verdict (the allow/deny result) is not sensitive and should be present
expect(attrs['verdict']).toBe('allow');
});
it('should omit sensitive fields from verdict Clearcut when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaVerdictEvent(
'sensitive prompt',
'sensitive policy',
'sensitive tool call',
'allow',
'sensitive rationale',
'some error',
);
logConsecaVerdict(configNoPrompts, event);
expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith(
expect.anything(),
[
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT,
value: '"allow"',
},
{
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
value: 'some error',
},
],
);
});
it('should include sensitive fields in verdict OTEL when logPrompts is enabled', () => {
const event = new ConsecaVerdictEvent(
'visible prompt',
'visible policy',
'visible tool call',
'deny',
'visible rationale',
);
logConsecaVerdict(mockConfig, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBe('visible prompt');
expect(attrs['policy']).toBe('visible policy');
expect(attrs['tool_call']).toBe('visible tool call');
expect(attrs['verdict_rationale']).toBe('visible rationale');
expect(attrs['verdict']).toBe('deny');
});
});
+31 -41
View File
@@ -11,7 +11,6 @@ import { isTelemetrySdkInitialized } from './sdk.js';
import {
ClearcutLogger,
EventNames,
type EventValue,
} from './clearcut-logger/clearcut-logger.js';
import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
@@ -28,24 +27,20 @@ export function logConsecaPolicyGeneration(
debugLogger.debug('Conseca Policy Generation Event:', event);
const clearcutLogger = ClearcutLogger.getInstance(config);
if (clearcutLogger) {
const data: EventValue[] = [];
if (config.getTelemetryLogPromptsEnabled()) {
data.push(
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT,
value: safeJsonStringify(event.trusted_content),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
);
}
const data = [
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT,
value: safeJsonStringify(event.trusted_content),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
];
if (event.error) {
data.push({
@@ -76,34 +71,29 @@ export function logConsecaVerdict(
debugLogger.debug('Conseca Verdict Event:', event);
const clearcutLogger = ClearcutLogger.getInstance(config);
if (clearcutLogger) {
const data: EventValue[] = [
const data = [
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME,
value: safeJsonStringify(event.tool_call),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT,
value: safeJsonStringify(event.verdict),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE,
value: event.verdict_rationale,
},
];
if (config.getTelemetryLogPromptsEnabled()) {
data.push(
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME,
value: safeJsonStringify(event.tool_call),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE,
value: event.verdict_rationale,
},
);
}
if (event.error) {
data.push({
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
+15 -168
View File
@@ -642,54 +642,6 @@ describe('loggers', () => {
}),
});
});
it('should not include response_text when logPrompts is disabled', () => {
const mockConfigNoPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const event = new ApiResponseEvent(
'test-model',
100,
{ prompt_id: 'prompt-id-noprompts', contents: [] },
{ candidates: [] },
AuthType.LOGIN_WITH_GOOGLE,
{},
'this response should be hidden',
);
logApiResponse(mockConfigNoPrompts, event);
const firstEmitCall = mockLogger.emit.mock.calls[0][0];
expect(firstEmitCall.attributes['response_text']).toBeUndefined();
});
it('should include response_text when logPrompts is enabled', () => {
const event = new ApiResponseEvent(
'test-model',
100,
{ prompt_id: 'prompt-id-withprompts', contents: [] },
{ candidates: [] },
AuthType.LOGIN_WITH_GOOGLE,
{},
'this response should be visible',
);
logApiResponse(mockConfig, event);
const firstEmitCall = mockLogger.emit.mock.calls[0][0];
expect(firstEmitCall.attributes['response_text']).toBe(
'this response should be visible',
);
});
});
describe('logApiError', () => {
@@ -1124,10 +1076,6 @@ describe('loggers', () => {
expect(attributes['gen_ai.provider.name']).toBe('gcp.vertex_ai');
// Ensure prompt messages are NOT included
expect(attributes['gen_ai.input.messages']).toBeUndefined();
// Ensure request_text is also NOT included in the first (toLogRecord) log
const firstLogCall = mockLogger.emit.mock.calls[0][0];
expect(firstLogCall.attributes['request_text']).toBeUndefined();
});
it('should correctly derive model from prompt details if available in semantic log', () => {
@@ -1425,20 +1373,16 @@ describe('loggers', () => {
error_type: undefined,
mcp_server_name: undefined,
extension_id: undefined,
metadata: JSON.stringify(
{
model_added_lines: 1,
model_removed_lines: 2,
model_added_chars: 3,
model_removed_chars: 4,
user_added_lines: 5,
user_removed_lines: 6,
user_added_chars: 7,
user_removed_chars: 8,
},
null,
2,
),
metadata: {
model_added_lines: 1,
model_removed_lines: 2,
model_added_chars: 3,
model_removed_chars: 4,
user_added_lines: 5,
user_removed_lines: 6,
user_added_chars: 7,
user_removed_chars: 8,
},
content_length: 13,
},
});
@@ -1511,16 +1455,12 @@ describe('loggers', () => {
body: 'Tool call: ask_user. Decision: accept. Success: true. Duration: 100ms.',
attributes: expect.objectContaining({
function_name: 'ask_user',
metadata: JSON.stringify(
{
ask_user: {
question_types: ['choice'],
dismissed: false,
},
metadata: expect.objectContaining({
ask_user: {
question_types: ['choice'],
dismissed: false,
},
null,
2,
),
}),
}),
});
});
@@ -1927,99 +1867,6 @@ describe('loggers', () => {
});
});
describe('logToolCall — logPrompts flag', () => {
it('should omit function_args when logPrompts is disabled', () => {
const mockConfigNoPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const call: CompletedToolCall = {
status: CoreToolCallStatus.Success,
request: {
name: 'run_bash',
args: { command: 'echo sensitive' },
callId: 'call-1',
isClientInitiated: false,
prompt_id: 'prompt-noprompts',
},
response: {
callId: 'call-1',
responseParts: [],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: undefined,
},
tool: undefined as unknown as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
durationMs: 50,
};
const event = new ToolCallEvent(call);
logToolCall(mockConfigNoPrompts, event);
const emitted = mockLogger.emit.mock.calls[0][0] as {
attributes: Record<string, unknown>;
};
expect(emitted.attributes['function_args']).toBeUndefined();
expect(emitted.attributes['function_name']).toBe('run_bash');
});
it('should include function_args when logPrompts is enabled', () => {
const mockConfigWithPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const call: CompletedToolCall = {
status: CoreToolCallStatus.Success,
request: {
name: 'run_bash',
args: { command: 'echo visible' },
callId: 'call-2',
isClientInitiated: false,
prompt_id: 'prompt-withprompts',
},
response: {
callId: 'call-2',
responseParts: [],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: undefined,
},
tool: undefined as unknown as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
durationMs: 50,
};
const event = new ToolCallEvent(call);
logToolCall(mockConfigWithPrompts, event);
const emitted = mockLogger.emit.mock.calls[0][0] as {
attributes: Record<string, unknown>;
};
expect(emitted.attributes['function_args']).toBe(
JSON.stringify({ command: 'echo visible' }, null, 2),
);
});
});
describe('logMalformedJsonResponse', () => {
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logMalformedJsonResponseEvent');
+11 -57
View File
@@ -231,17 +231,6 @@ export class UserPromptEvent implements BaseTelemetryEvent {
}
export const EVENT_TOOL_CALL = 'gemini_cli.tool_call';
const TOOL_CALL_METADATA_SAFE_KEYS = [
'model_added_lines',
'model_removed_lines',
'model_added_chars',
'model_removed_chars',
'user_added_lines',
'user_removed_lines',
'user_added_chars',
'user_removed_chars',
] as const;
export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': 'tool_call';
'event.timestamp': string;
@@ -366,6 +355,7 @@ export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': EVENT_TOOL_CALL,
'event.timestamp': this['event.timestamp'],
function_name: this.function_name,
function_args: safeJsonStringify(this.function_args, 2),
duration_ms: this.duration_ms,
success: this.success,
decision: this.decision,
@@ -377,22 +367,8 @@ export class ToolCallEvent implements BaseTelemetryEvent {
extension_id: this.extension_id,
start_time: this.start_time,
end_time: this.end_time,
metadata: this.metadata,
};
if (config.getTelemetryLogPromptsEnabled() && this.function_args) {
attributes['function_args'] = safeJsonStringify(this.function_args, 2);
}
if (this.metadata) {
const metadata = config.getTelemetryLogPromptsEnabled()
? this.metadata
: Object.fromEntries(
Object.entries(this.metadata).filter(([k]) =>
(TOOL_CALL_METADATA_SAFE_KEYS as readonly string[]).includes(k),
),
);
if (Object.keys(metadata).length > 0) {
attributes['metadata'] = safeJsonStringify(metadata, 2);
}
}
if (this.error) {
attributes[CoreToolCallStatus.Error] = this.error;
@@ -447,10 +423,8 @@ export class ApiRequestEvent implements BaseTelemetryEvent {
'event.timestamp': this['event.timestamp'],
model: this.model,
prompt_id: this.prompt.prompt_id,
request_text: this.request_text,
};
if (config.getTelemetryLogPromptsEnabled() && this.request_text) {
attributes['request_text'] = this.request_text;
}
if (this.role) {
attributes['role'] = this.role;
}
@@ -718,7 +692,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
if (this.role) {
attributes['role'] = this.role;
}
if (config.getTelemetryLogPromptsEnabled() && this.response_text) {
if (this.response_text) {
attributes['response_text'] = this.response_text;
}
if (this.status_code) {
@@ -980,20 +954,11 @@ export class ConsecaPolicyGenerationEvent implements BaseTelemetryEvent {
...getCommonAttributes(config),
'event.name': EVENT_CONSECA_POLICY_GENERATION,
'event.timestamp': this['event.timestamp'],
user_prompt: this.user_prompt,
trusted_content: this.trusted_content,
policy: this.policy,
};
if (config.getTelemetryLogPromptsEnabled()) {
if (this.user_prompt) {
attributes['user_prompt'] = this.user_prompt;
}
if (this.trusted_content) {
attributes['trusted_content'] = this.trusted_content;
}
if (this.policy) {
attributes['policy'] = this.policy;
}
}
if (this.error) {
attributes['error'] = this.error;
}
@@ -1040,24 +1005,13 @@ export class ConsecaVerdictEvent implements BaseTelemetryEvent {
...getCommonAttributes(config),
'event.name': EVENT_CONSECA_VERDICT,
'event.timestamp': this['event.timestamp'],
user_prompt: this.user_prompt,
policy: this.policy,
tool_call: this.tool_call,
verdict: this.verdict,
verdict_rationale: this.verdict_rationale,
};
if (config.getTelemetryLogPromptsEnabled()) {
if (this.user_prompt) {
attributes['user_prompt'] = this.user_prompt;
}
if (this.policy) {
attributes['policy'] = this.policy;
}
if (this.tool_call) {
attributes['tool_call'] = this.tool_call;
}
if (this.verdict_rationale) {
attributes['verdict_rationale'] = this.verdict_rationale;
}
}
if (this.error) {
attributes['error'] = this.error;
}
+23 -8
View File
@@ -12,6 +12,15 @@ export enum ReleaseChannel {
STABLE = 'stable',
}
/**
* Stability ranking for release channels. Higher number means more stable.
*/
export const RELEASE_CHANNEL_STABILITY: Record<ReleaseChannel, number> = {
[ReleaseChannel.NIGHTLY]: 0,
[ReleaseChannel.PREVIEW]: 1,
[ReleaseChannel.STABLE]: 2,
};
const cache = new Map<string, ReleaseChannel>();
/**
@@ -22,6 +31,19 @@ export function _clearCache() {
cache.clear();
}
/**
* Determines the release channel for a given version string.
*/
export function getChannelFromVersion(version: string): ReleaseChannel {
if (!version || version.includes('nightly')) {
return ReleaseChannel.NIGHTLY;
}
if (version.includes('preview')) {
return ReleaseChannel.PREVIEW;
}
return ReleaseChannel.STABLE;
}
export async function getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
if (cache.has(cwd)) {
return cache.get(cwd)!;
@@ -30,14 +52,7 @@ export async function getReleaseChannel(cwd: string): Promise<ReleaseChannel> {
const packageJson = await getPackageJson(cwd);
const version = packageJson?.version ?? '';
let channel: ReleaseChannel;
if (version.includes('nightly') || version === '') {
channel = ReleaseChannel.NIGHTLY;
} else if (version.includes('preview')) {
channel = ReleaseChannel.PREVIEW;
} else {
channel = ReleaseChannel.STABLE;
}
const channel = getChannelFromVersion(version);
cache.set(cwd, channel);
return channel;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"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.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"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.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"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.41.0-preview.0",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {